GuildHelper = GuildHelper or {}

local addon = GuildHelper

addon.RosterService = addon.RosterService or {}

local MYTOON_TOMBSTONE_PURGE_AFTER_DAYS = 90
local GUILD_NEWS_REQUEST_COOLDOWN_SECONDS = 30
local JOIN_DATE_CANDIDATE_SOURCE_OFFICER_NOTE = "officer-note"
local debugProfileStop = type(debugprofilestop) == "function" and debugprofilestop or nil
local trimString

local classProfiles = {
    DEATHKNIGHT = { defaultRole = "dps", damageProfile = "melee", roleOptions = { "tank", "dps" } },
    DEMONHUNTER = { defaultRole = "dps", damageProfile = "hybrid", roleOptions = { "tank", "dps" } },
    DRUID = { defaultRole = "dps", damageProfile = "hybrid", roleOptions = { "tank", "healer", "dps" } },
    EVOKER = { defaultRole = "dps", damageProfile = "ranged", roleOptions = { "healer", "dps" } },
    HUNTER = { defaultRole = "dps", damageProfile = "hybrid", roleOptions = { "dps" } },
    MAGE = { defaultRole = "dps", damageProfile = "ranged", roleOptions = { "dps" } },
    MONK = { defaultRole = "dps", damageProfile = "hybrid", roleOptions = { "tank", "healer", "dps" } },
    PALADIN = { defaultRole = "dps", damageProfile = "melee", roleOptions = { "tank", "healer", "dps" } },
    PRIEST = { defaultRole = "healer", damageProfile = "ranged", roleOptions = { "healer", "dps" } },
    ROGUE = { defaultRole = "dps", damageProfile = "melee", roleOptions = { "dps" } },
    SHAMAN = { defaultRole = "dps", damageProfile = "hybrid", roleOptions = { "healer", "dps" } },
    WARLOCK = { defaultRole = "dps", damageProfile = "ranged", roleOptions = { "dps" } },
    WARRIOR = { defaultRole = "dps", damageProfile = "melee", roleOptions = { "tank", "dps" } },
}

local function parseFullName(fullName)
    return addon.Store:SplitFullName(fullName, GetRealmName() or "Unknown")
end

local function getCachedRosterNameInfo(service, rawName, fallbackRealm)
    if type(rawName) ~= "string" or rawName == "" then
        return nil, nil, nil
    end

    service._normalizedRosterNameCache = type(service._normalizedRosterNameCache) == "table" and service._normalizedRosterNameCache or {}

    local resolvedFallbackRealm = addon.Store:NormalizeRealmName(fallbackRealm, fallbackRealm)

    if type(resolvedFallbackRealm) ~= "string" or resolvedFallbackRealm == "" then
        local trimmedFallbackRealm = trimString(fallbackRealm)
        resolvedFallbackRealm = trimmedFallbackRealm ~= "" and trimmedFallbackRealm or "Unknown"
    end

    local cacheKey = resolvedFallbackRealm .. "\31" .. rawName
    local cached = service._normalizedRosterNameCache[cacheKey]

    if cached then
        return cached.normalizedName, cached.characterName, cached.realmName
    end

    local trimmedName = trimString(rawName)
    local dashIndex = trimmedName:find("-", 1, true)
    local characterName = trimmedName
    local realmName = resolvedFallbackRealm

    if dashIndex then
        characterName = trimString(trimmedName:sub(1, dashIndex - 1))
        realmName = addon.Store:NormalizeRealmName(trimmedName:sub(dashIndex + 1), resolvedFallbackRealm) or resolvedFallbackRealm
    end

    local normalizedName = realmName ~= "" and (characterName .. "-" .. realmName) or characterName

    service._normalizedRosterNameCache[cacheKey] = {
        normalizedName = normalizedName,
        characterName = characterName,
        realmName = realmName,
    }

    return normalizedName, characterName, realmName
end

local function getRosterPerfClockMilliseconds()
    if debugProfileStop then
        return tonumber(debugProfileStop()) or 0
    end

    if type(GetTimePreciseSec) == "function" then
        return (tonumber(GetTimePreciseSec()) or 0) * 1000
    end

    if type(GetTime) == "function" then
        return (tonumber(GetTime()) or 0) * 1000
    end

    return (tonumber(time()) or 0) * 1000
end

local function beginRosterPerfScope(scopeName)
    if addon.BeginPerfScope then
        return addon:BeginPerfScope(scopeName)
    end

    return nil
end

local function endRosterPerfScope(scopeName, startedAt)
    if startedAt ~= nil and addon.EndPerfScope then
        addon:EndPerfScope(scopeName, startedAt)
    end
end

local function recordRosterPerfDuration(scopeName, elapsedMs, recordedAt)
    if not addon.RecordPerfScopeDuration or (tonumber(elapsedMs) or 0) <= 0 then
        return nil
    end

    return addon:RecordPerfScopeDuration(scopeName, elapsedMs, recordedAt)
end

local function estimateLastSeenAtFromGuildRoster(index, isOnline, fallbackTimestamp)
    if isOnline then
        return time()
    end

    if not GetGuildRosterLastOnline then
        return fallbackTimestamp
    end

    local ok, years, months, days, hours = pcall(GetGuildRosterLastOnline, index)

    if not ok then
        return fallbackTimestamp
    end

    years = tonumber(years) or 0
    months = tonumber(months) or 0
    days = tonumber(days) or 0
    hours = tonumber(hours) or 0

    local elapsedSeconds = (((years * 365) + (months * 30) + days) * 24 * 3600) + (hours * 3600)

    if elapsedSeconds <= 0 then
        return fallbackTimestamp
    end

    return time() - elapsedSeconds
end

local function resolveExistingRosterLastSeenAt(index, isOnline, previouslyOnline, previousLastSeenAt)
    local fallbackTimestamp = tonumber(previousLastSeenAt) or 0

    if isOnline then
        return time()
    end

    if fallbackTimestamp > 0 and not previouslyOnline then
        return fallbackTimestamp
    end

    return estimateLastSeenAtFromGuildRoster(index, false, fallbackTimestamp > 0 and fallbackTimestamp or time())
end

local function makeGuildMemberRecord(fullName, guildKey, classFileName, className, level, rank, online, note, lastSeenAt)
    fullName = addon.Store:NormalizeFullName(fullName, GetRealmName() or "Unknown")
    local profile = classProfiles[classFileName] or { defaultRole = "dps", damageProfile = "unknown", roleOptions = { "dps" } }
    local characterName, realmName = parseFullName(fullName)

    return {
        id = fullName,
        fullName = fullName,
        name = characterName,
        realm = realmName,
        guildName = guildKey,
        faction = UnitFactionGroup("player") or "Unknown",
        classFile = classFileName,
        className = className,
        level = level or 0,
        rank = rank or "",
        online = online and true or false,
        primaryRole = profile.defaultRole,
        secondaryRoles = profile.roleOptions,
        damageProfile = profile.damageProfile,
        meleeOrRanged = profile.damageProfile,
        itemLevel = 0,
        spec = nil,
        battleTag = nil,
        birthdate = nil,
        professions = {},
        interests = {},
        mainCharacter = fullName,
        guildJoinDate = nil,
        note = note or "",
        guildRosterConfirmed = false,
        lastGuildRosterSeenAt = 0,
        lastSeenAt = tonumber(lastSeenAt) or time(),
        noteCapturedAt = 0,
        updatedAt = time(),
        updatedBy = addon.Store:GetPlayerName(),
        revision = 1,
        deleted = false,
    }
end

local function confirmGuildRosterPresence(record, seenAt)
    if type(record) ~= "table" then
        return false
    end

    local changed = false
    local now = tonumber(seenAt) or time()

    if not record.guildRosterConfirmed then
        record.guildRosterConfirmed = true
        changed = true
    end

    if (tonumber(record.lastGuildRosterSeenAt) or 0) <= 0 then
        record.lastGuildRosterSeenAt = now
        changed = true
    end

    return changed
end

local function markRosterRecordSynced(record, updatedAt)
    if type(record) ~= "table" then
        return
    end

    record.updatedAt = tonumber(updatedAt) or time()
    record.updatedBy = addon.Store:GetPlayerName()
    record.revision = (record.revision or 0) + 1
end

local function getRosterSyncSignature(record)
    if type(record) ~= "table"
        or type(addon.Reconciler) ~= "table"
        or type(addon.Reconciler.BuildRecordSignature) ~= "function" then
        return nil
    end

    return addon.Reconciler:BuildRecordSignature("roster", record.id or record.fullName, record)
end

local function ensureRosterSyncSignature(record, previousSignature)
    if previousSignature ~= nil then
        return previousSignature
    end

    return getRosterSyncSignature(record)
end

local function recordRosterSyncMutation(record, previousSignature, updatedAt)
    if type(record) ~= "table" then
        return false
    end

    markRosterRecordSynced(record, updatedAt)
    addon.Store:GetSync().tableMeta.roster.updatedAt = record.updatedAt or time()

    local nextSignature = getRosterSyncSignature(record)

    if previousSignature ~= nextSignature
        and type(addon.Reconciler) == "table"
        and type(addon.Reconciler.AppendRosterJournalRecord) == "function" then
        addon.Reconciler:AppendRosterJournalRecord(record)
    end

    return previousSignature ~= nextSignature
end

local function cloneList(source)
    local copy = {}

    for index, value in ipairs(source or {}) do
        copy[index] = value
    end

    return copy
end

local function cloneValue(value)
    if type(value) ~= "table" then
        return value
    end

    local copy = {}

    for key, nestedValue in pairs(value) do
        copy[key] = cloneValue(nestedValue)
    end

    return copy
end

local function getMyToonTombstonePurgeCutoff()
    return time() - (MYTOON_TOMBSTONE_PURGE_AFTER_DAYS * 86400)
end

local MYTOON_SYNC_FIELDS = {
    "spec",
    "itemLevel",
    "primaryRole",
    "meleeOrRanged",
    "secondaryRoles",
    "mainCharacter",
    "guildJoinDate",
    "birthdate",
    "battleTag",
    "professions",
    "interests",
    "note",
}

local function scrubSensitiveMyToonFields(record)
    if type(record) ~= "table" then
        return false
    end

    if record.officerNote ~= nil then
        record.officerNote = nil
        return true
    end

    return false
end

local function scrubSensitiveRosterFields(record)
    if type(record) ~= "table" then
        return false
    end

    if record.officerNote ~= nil then
        record.officerNote = nil
        return true
    end

    return false
end

local function isDeletedMyToonRecord(record)
    return type(record) == "table" and record.deleted and true or false
end

local function isLocalMyToonId(memberId)
    local knownCharacters = addon.Store:GetProfile().myCharacters or {}
    local normalizedId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

    return knownCharacters[normalizedId] ~= nil
end

local function getCachedPlayerMainCharacter()
    local playerId = addon.Store:GetPlayerName()

    if (tostring(playerId or ""):match("^%s*(.-)%s*$") or "") == "" then
        return nil
    end

    local myToonRecord = addon.Store:GetMyToons()[playerId]

    if type(myToonRecord) == "table" and not isDeletedMyToonRecord(myToonRecord) and (tostring(myToonRecord.mainCharacter or ""):match("^%s*(.-)%s*$") or "") ~= "" then
        return addon.Store:NormalizeFullName(myToonRecord.mainCharacter, GetRealmName() or "Unknown")
    end

    local guildStore = addon.Store:GetGuild()
    local roster = guildStore and guildStore.roster or nil
    local playerRecord = type(roster) == "table" and roster[playerId] or nil

    if type(playerRecord) == "table" and (tostring(playerRecord.mainCharacter or ""):match("^%s*(.-)%s*$") or "") ~= "" then
        return addon.Store:NormalizeFullName(playerRecord.mainCharacter, GetRealmName() or "Unknown")
    end

    return playerId
end

local function overlayMyToonData(member, myToonRecord)
    if type(member) ~= "table" or type(myToonRecord) ~= "table" or isDeletedMyToonRecord(myToonRecord) then
        return member
    end

    local preserveLiveFields = member.id == addon.Store:GetPlayerName()

    for _, fieldName in ipairs(MYTOON_SYNC_FIELDS) do
        if (not preserveLiveFields or (fieldName ~= "spec" and fieldName ~= "itemLevel")) and myToonRecord[fieldName] ~= nil then
            member[fieldName] = cloneValue(myToonRecord[fieldName])
        end
    end

    if myToonRecord.updatedAt then
        member.updatedAt = myToonRecord.updatedAt
    end

    if myToonRecord.updatedBy then
        member.updatedBy = myToonRecord.updatedBy
    end

    if myToonRecord.revision then
        member.revision = myToonRecord.revision
    end

    member.myToonOwner = myToonRecord.owner
    return member
end

local function applyVisibleOnlineState(member)
    if type(member) ~= "table" then
        return member
    end

    local currentGuildKey = addon.Store:GetGuildKey()
    member.online = currentGuildKey ~= nil and member.guildName == currentGuildKey and member.online and true or false
    return member
end

local function applyMyToonFieldsToRosterRecord(rosterRecord, myToonRecord)
    if type(rosterRecord) ~= "table" or type(myToonRecord) ~= "table" or isDeletedMyToonRecord(myToonRecord) then
        return false
    end

    local changed = false
    local preserveLiveFields = rosterRecord.id == addon.Store:GetPlayerName()

    for _, fieldName in ipairs(MYTOON_SYNC_FIELDS) do
        local incomingValue = myToonRecord[fieldName]

        if (not preserveLiveFields or (fieldName ~= "spec" and fieldName ~= "itemLevel")) and incomingValue ~= nil then
            if type(incomingValue) == "table" then
                rosterRecord[fieldName] = cloneValue(incomingValue)
                changed = true
            elseif rosterRecord[fieldName] ~= incomingValue then
                rosterRecord[fieldName] = incomingValue
                changed = true
            end
        end
    end

    return changed
end

local function getRoleOptionsForClass(classFileName)
    local profile = classProfiles[classFileName] or {}
    return cloneList(profile.roleOptions or { "dps" })
end

local function getProfileOptionsForClass(classFileName)
    local profile = classProfiles[classFileName] or {}

    if profile.damageProfile == "hybrid" then
        return { "melee", "ranged", "hybrid" }
    end

    if profile.damageProfile == "melee" then
        return { "melee" }
    end

    if profile.damageProfile == "ranged" then
        return { "ranged" }
    end

    return { "unknown" }
end
local function parseCsvList(value)
    local seen = {}
    local list = {}

    for token in string.gmatch(value or "", "([^,]+)") do
        local item = token:match("^%s*(.-)%s*$")

        if item ~= "" and not seen[item] then
            seen[item] = true
            table.insert(list, item)
        end
    end

    return list
end

trimString = function(value)
    return (tostring(value or ""):match("^%s*(.-)%s*$"))
end

local function normalizeList(value)
    if type(value) == "table" then
        local seen = {}
        local list = {}

        for _, item in ipairs(value) do
            local normalized = trimString(item)

            if normalized ~= "" and not seen[normalized] then
                seen[normalized] = true
                list[#list + 1] = normalized
            end
        end

        return list
    end

    return parseCsvList(value)
end

local function normalizeDate(value)
    local normalized = trimString(value)

    if normalized == "" then
        return nil
    end

    if string.match(normalized, "^%d%d%d%d%-%d%d%-%d%d$") then
        return normalized
    end

    return normalized
end

local MONTH_NAME_LOOKUP = {
    jan = 1,
    january = 1,
    feb = 2,
    february = 2,
    mar = 3,
    march = 3,
    apr = 4,
    april = 4,
    may = 5,
    jun = 6,
    june = 6,
    jul = 7,
    july = 7,
    aug = 8,
    august = 8,
    sep = 9,
    sept = 9,
    september = 9,
    oct = 10,
    october = 10,
    nov = 11,
    november = 11,
    dec = 12,
    december = 12,
}

local function isLeapYear(year)
    return (year % 4 == 0 and year % 100 ~= 0) or (year % 400 == 0)
end

local function getDaysInMonth(month, year)
    local monthDays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }

    if month == 2 and isLeapYear(year) then
        return 29
    end

    return monthDays[month] or 31
end

local function normalizeYearValue(value)
    local year = tonumber(value)

    if not year then
        return nil
    end

    if year < 100 then
        local currentTwoDigitYear = tonumber(date("%y")) or 0

        if year <= (currentTwoDigitYear + 1) then
            year = 2000 + year
        else
            year = 1900 + year
        end
    end

    if year < 1900 or year > 2100 then
        return nil
    end

    return year
end

local function buildIsoDate(yearValue, monthValue, dayValue)
    local year = normalizeYearValue(yearValue)
    local month = tonumber(monthValue)
    local day = tonumber(dayValue)

    if not year or not month or not day then
        return nil
    end

    if month < 1 or month > 12 then
        return nil
    end

    if day < 1 or day > getDaysInMonth(month, year) then
        return nil
    end

    return string.format("%04d-%02d-%02d", year, month, day)
end

local function resolveMonthName(monthToken)
    local normalized = string.lower(trimString(monthToken or ""))
    return MONTH_NAME_LOOKUP[normalized]
end

local function parseJoinDateFromOfficerNoteText(value)
    local text = trimString(value)

    if text == "" then
        return nil
    end

    local year, month, day = text:match("(%d%d%d%d)%-(%d%d?)%-(%d%d?)")

    if year and month and day then
        return buildIsoDate(year, month, day)
    end

    local first, second, third = text:match("(%d%d?)%/(%d%d?)%/(%d%d%d?%d?)")

    if first and second and third then
        return buildIsoDate(third, first, second)
    end

    first, second, third = text:match("(%d%d?)%-(%d%d?)%-(%d%d%d?%d?)")

    if first and second and third then
        return buildIsoDate(third, first, second)
    end

    local dayValue, monthName, yearValue = text:match("(%d%d?)%s+([A-Za-z]+)%s+'?(%d%d%d?%d?)")
    local monthNumber = resolveMonthName(monthName)

    if dayValue and monthNumber and yearValue then
        return buildIsoDate(yearValue, monthNumber, dayValue)
    end

    monthName, dayValue, yearValue = text:match("([A-Za-z]+)%s+(%d%d?),?%s+'?(%d%d%d?%d?)")
    monthNumber = resolveMonthName(monthName)

    if dayValue and monthNumber and yearValue then
        return buildIsoDate(yearValue, monthNumber, dayValue)
    end

    return nil
end

local function getLocalMyCharacterMeta(memberId, createIfMissing)
    local normalizedId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    local profile = addon.Store:GetProfile()

    if type(profile.myCharacters) ~= "table" then
        profile.myCharacters = {}
    end

    local entry = profile.myCharacters[normalizedId]

    if type(entry) ~= "table" and createIfMissing then
        entry = {
            firstSeenAt = time(),
            lastSeenAt = time(),
        }
        profile.myCharacters[normalizedId] = entry
    end

    return normalizedId, entry
end

local function clearStoredJoinDateCandidate(memberId)
    local _, entry = getLocalMyCharacterMeta(memberId, false)

    if type(entry) ~= "table" or entry.joinDateCandidate == nil then
        return false
    end

    entry.joinDateCandidate = nil
    return true
end

local function storeJoinDateCandidate(memberId, dateValue, source)
    local normalizedId, entry = getLocalMyCharacterMeta(memberId, true)
    local candidateValue = normalizeDate(dateValue)

    if candidateValue == nil then
        return false
    end

    if type(entry) ~= "table" then
        return false
    end

    local existing = type(entry.joinDateCandidate) == "table" and entry.joinDateCandidate or nil

    if existing
        and existing.value == candidateValue
        and existing.source == source
        and existing.pending == true then
        return false
    end

    entry.joinDateCandidate = {
        value = candidateValue,
        source = source or JOIN_DATE_CANDIDATE_SOURCE_OFFICER_NOTE,
        capturedAt = time(),
        capturedBy = addon.Store:GetPlayerName(),
        pending = true,
        toonId = normalizedId,
    }

    return true
end

local function isIsoDateString(value)
    return type(value) == "string" and string.match(value, "^%d%d%d%d%-%d%d%-%d%d$") ~= nil
end

local function shouldReplaceGuildJoinDate(existingValue, nextValue)
    local existingDate = normalizeDate(existingValue)
    local candidateDate = normalizeDate(nextValue)

    if candidateDate == nil then
        return false
    end

    if existingDate == nil then
        return true
    end

    if existingDate == candidateDate then
        return false
    end

    local existingIsIso = isIsoDateString(existingDate)
    local candidateIsIso = isIsoDateString(candidateDate)

    if candidateIsIso and not existingIsIso then
        return true
    end

    if existingIsIso and candidateIsIso then
        return candidateDate > existingDate
    end

    return false
end

local function ensureGuildNewsRuntime()
    addon.RosterService._guildNewsRuntime = type(addon.RosterService._guildNewsRuntime) == "table" and addon.RosterService._guildNewsRuntime or {}

    local runtime = addon.RosterService._guildNewsRuntime
    runtime.joinTypes = type(runtime.joinTypes) == "table" and runtime.joinTypes or {}
    runtime.lastRequestedAt = tonumber(runtime.lastRequestedAt) or 0
    return runtime
end

local function getGuildNewsCount()
    if C_GuildInfo and type(C_GuildInfo.GetNumGuildNews) == "function" then
        local ok, count = pcall(C_GuildInfo.GetNumGuildNews)

        if ok and tonumber(count) then
            return math.max(0, math.floor(tonumber(count)))
        end
    end

    if type(GetNumGuildNews) == "function" then
        local ok, count = pcall(GetNumGuildNews)

        if ok and tonumber(count) then
            return math.max(0, math.floor(tonumber(count)))
        end
    end

    return 0
end

local function getGuildNewsInfoRecord(index)
    if C_GuildInfo and type(C_GuildInfo.GetGuildNewsInfo) == "function" then
        local ok, newsInfo = pcall(C_GuildInfo.GetGuildNewsInfo, index)

        if ok and type(newsInfo) == "table" then
            return newsInfo
        end
    end

    if type(GetGuildNewsInfo) == "function" then
        local ok,
            isSticky,
            isHeader,
            newsType,
            whoText,
            whatText,
            newsDataID,
            data,
            weekday,
            day,
            month,
            year,
            guildMembersPresent = pcall(GetGuildNewsInfo, index)

        if ok and (isSticky ~= nil or isHeader ~= nil or newsType ~= nil) then
            return {
                data = type(data) == "table" and data or {},
                day = day,
                guildMembersPresent = guildMembersPresent,
                isHeader = isHeader,
                isSticky = isSticky,
                month = month,
                newsDataID = newsDataID,
                newsType = newsType,
                whatText = whatText,
                weekday = weekday,
                whoText = whoText,
                year = year,
            }
        end
    end

    return nil
end

local function requestGuildNewsRefresh()
    local runtime = ensureGuildNewsRuntime()
    local now = time()

    if (now - runtime.lastRequestedAt) < GUILD_NEWS_REQUEST_COOLDOWN_SECONDS then
        return false
    end

    runtime.lastRequestedAt = now

    if C_GuildInfo and type(C_GuildInfo.QueryGuildNews) == "function" then
        local ok = pcall(C_GuildInfo.QueryGuildNews)

        if ok then
            return true
        end
    end

    if type(QueryGuildNews) == "function" then
        local ok = pcall(QueryGuildNews)

        if ok then
            return true
        end
    end

    if C_GuildInfo and type(C_GuildInfo.GuildRoster) == "function" then
        local ok = pcall(C_GuildInfo.GuildRoster)

        if ok then
            return true
        end
    end

    if type(GuildRoster) == "function" then
        local ok = pcall(GuildRoster)

        if ok then
            return true
        end
    end

    return false
end

local function normalizeGuildNewsMemberId(value)
    local memberName = trimString(value)

    if memberName == "" then
        return ""
    end

    if C_StringUtil and type(C_StringUtil.StripHyperlinks) == "function" then
        local ok, strippedValue = pcall(C_StringUtil.StripHyperlinks, memberName)

        if ok and trimString(strippedValue) ~= "" then
            memberName = trimString(strippedValue)
        end
    else
        memberName = memberName:gsub("|H.-|h(.-)|h", "%1")
        memberName = memberName:gsub("|c%x%x%x%x%x%x%x%x", "")
        memberName = memberName:gsub("|r", "")
        memberName = trimString(memberName)
    end

    if memberName == "" then
        return ""
    end

    return addon.Store:NormalizeFullName(memberName, GetRealmName() or "Unknown")
end

local function mergeGuildNewsTypeSet(target, source)
    if type(target) ~= "table" or type(source) ~= "table" then
        return
    end

    for newsType, enabled in pairs(source) do
        if enabled then
            target[tonumber(newsType) or newsType] = true
        end
    end
end

local function absorbGuildNewsEnumJoinTypes(target, source)
    if type(target) ~= "table" or type(source) ~= "table" then
        return
    end

    for key, value in pairs(source) do
        local numericValue = tonumber(value)
        local collapsedKey = string.lower(tostring(key or "")):gsub("[^%a%d]", "")

        if numericValue
            and (collapsedKey:find("join", 1, true)
                or collapsedKey:find("newmember", 1, true)
                or collapsedKey:find("memberadded", 1, true)
                or collapsedKey:find("guildmemberadded", 1, true)) then
            target[numericValue] = true
        end
    end
end

local function resolveGuildNewsJoinTypes(statsByType)
    local runtime = ensureGuildNewsRuntime()
    local joinTypes = {}

    mergeGuildNewsTypeSet(joinTypes, runtime.joinTypes)
    absorbGuildNewsEnumJoinTypes(joinTypes, type(Enum) == "table" and Enum.GuildNewsType or nil)
    absorbGuildNewsEnumJoinTypes(joinTypes, type(Enum) == "table" and Enum.GuildNews or nil)
    absorbGuildNewsEnumJoinTypes(joinTypes, type(_G) == "table" and _G.GuildNewsType or nil)

    if next(joinTypes) ~= nil then
        runtime.joinTypes = joinTypes
        return joinTypes
    end

    for newsType, stats in pairs(statsByType or {}) do
        if type(stats) == "table"
            and (tonumber(stats.matchingMembers) or 0) > 0
            and (tonumber(stats.withWhatText) or 0) == 0
            and (tonumber(stats.withData) or 0) == 0 then
            joinTypes[tonumber(newsType) or newsType] = true
        end
    end

    runtime.joinTypes = joinTypes
    return joinTypes
end

local function buildGuildNewsJoinDateLookup(memberIdSet, requestIfUnavailable)
    local guild = addon.Store:GetGuild()
    local roster = type(guild) == "table" and type(guild.roster) == "table" and guild.roster or nil
    local newsCount = getGuildNewsCount()

    local function matchesMember(memberId)
        if memberId == "" then
            return false
        end

        if type(memberIdSet) == "table" then
            return memberIdSet[memberId] and true or false
        end

        return type(roster) == "table" and type(roster[memberId]) == "table"
    end

    if newsCount <= 0 then
        if requestIfUnavailable then
            requestGuildNewsRefresh()
        end

        return {}
    end

    local entries = {}
    local statsByType = {}

    for index = 1, newsCount do
        local newsInfo = getGuildNewsInfoRecord(index)

        if type(newsInfo) == "table" and not newsInfo.isHeader then
            local memberId = normalizeGuildNewsMemberId(newsInfo.whoText)
            local joinDate = buildIsoDate(newsInfo.year, newsInfo.month, newsInfo.day)
            local newsType = tonumber(newsInfo.newsType)
            local hasWhatText = trimString(newsInfo.whatText) ~= ""
            local hasData = type(newsInfo.data) == "table" and #newsInfo.data > 0

            if newsType ~= nil then
                local stats = statsByType[newsType]

                if type(stats) ~= "table" then
                    stats = {
                        matchingMembers = 0,
                        withData = 0,
                        withWhatText = 0,
                    }
                    statsByType[newsType] = stats
                end

                if hasWhatText then
                    stats.withWhatText = stats.withWhatText + 1
                end

                if hasData then
                    stats.withData = stats.withData + 1
                end

                if matchesMember(memberId) then
                    stats.matchingMembers = stats.matchingMembers + 1
                end
            end

            if newsType ~= nil and memberId ~= "" and joinDate ~= nil then
                entries[#entries + 1] = {
                    joinDate = joinDate,
                    memberId = memberId,
                    newsType = newsType,
                }
            end
        end
    end

    local joinTypes = resolveGuildNewsJoinTypes(statsByType)

    if next(joinTypes) == nil then
        return {}
    end

    local latestJoinDates = {}

    for _, entry in ipairs(entries) do
        if joinTypes[entry.newsType] and matchesMember(entry.memberId) then
            local currentDate = latestJoinDates[entry.memberId]

            if currentDate == nil or entry.joinDate > currentDate then
                latestJoinDates[entry.memberId] = entry.joinDate
            end
        end
    end

    return latestJoinDates
end

local function setMemberGuildJoinDate(memberId, dateValue)
    local normalizedId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    local normalizedDate = normalizeDate(dateValue)
    local record = addon.RosterService:GetMember(normalizedId)

    if normalizedId == "" or normalizedDate == nil or type(record) ~= "table" then
        return false
    end

    if not shouldReplaceGuildJoinDate(record.guildJoinDate, normalizedDate) then
        return false
    end

    local now = time()
    local previousSyncSignature = getRosterSyncSignature(record)

    record.guildJoinDate = normalizedDate

    if isLocalMyToonId(normalizedId) then
        local myToonRecord = addon.Store:GetMyToons()[normalizedId]

        if type(myToonRecord) == "table" and not isDeletedMyToonRecord(myToonRecord) then
            myToonRecord.guildJoinDate = normalizedDate
            myToonRecord.lastSeenAt = now
            myToonRecord.updatedAt = now
            myToonRecord.updatedBy = addon.Store:GetPlayerName()
            myToonRecord.revision = (myToonRecord.revision or 0) + 1
            addon.Store:GetSync().tableMeta.mytoons.updatedAt = now
        end

        clearStoredJoinDateCandidate(normalizedId)
    end

    recordRosterSyncMutation(record, previousSyncSignature, now)
    return true
end

local function containsValue(list, expected)
    for _, value in ipairs(list or {}) do
        if value == expected then
            return true
        end
    end

    return false
end

local function normalizeChoice(value, allowed, fallback)
    local normalized = string.lower(trimString(value))

    if normalized ~= "" and containsValue(allowed, normalized) then
        return normalized
    end

    if fallback and containsValue(allowed, fallback) then
        return fallback
    end

    return allowed[1]
end

local function getLivePlayerSpecName()
    local currentSpec = GetSpecialization and GetSpecialization() or nil

    if not currentSpec or not GetSpecializationInfo then
        return nil
    end

    local specName = select(2, GetSpecializationInfo(currentSpec))
    return specName ~= "" and specName or nil
end

local function getLivePlayerItemLevel()
    if not GetAverageItemLevel then
        return 0
    end

    local averageItemLevel, equippedItemLevel = GetAverageItemLevel()
    local resolvedItemLevel = equippedItemLevel or averageItemLevel or 0
    return tonumber(resolvedItemLevel) or 0
end

local function applyLivePlayerData(record)
    if not record or record.id ~= addon.Store:GetPlayerName() then
        return false
    end

    local changed = false
    local className, classFile = UnitClass("player")
    local level = UnitLevel("player") or record.level or 0
    local faction = UnitFactionGroup("player") or record.faction or "Unknown"
    local specName = getLivePlayerSpecName()
    local itemLevel = getLivePlayerItemLevel()

    if record.level ~= level then
        record.level = level
        changed = true
    end

    if className and record.className ~= className then
        record.className = className
        changed = true
    end

    if classFile and record.classFile ~= classFile then
        record.classFile = classFile
        changed = true
    end

    if record.faction ~= faction then
        record.faction = faction
        changed = true
    end

    if record.spec ~= specName then
        record.spec = specName
        changed = true
    end

    if record.itemLevel ~= itemLevel then
        record.itemLevel = itemLevel
        changed = true
    end

    return changed
end

local function ensureKnownPlayerCharacter(characterId)
    characterId = addon.Store:NormalizeFullName(characterId, GetRealmName() or "Unknown")
    local profile = addon.Store:GetProfile()
    local myToons = addon.Store:GetMyToons()

    if type(profile.myCharacters) ~= "table" then
        profile.myCharacters = {}
    end

    local existing = profile.myCharacters[characterId]
    local now = time()

    if type(existing) ~= "table" then
        profile.myCharacters[characterId] = {
            firstSeenAt = now,
            lastSeenAt = now,
        }

        if type(myToons[characterId]) ~= "table" then
            myToons[characterId] = {
                id = characterId,
                owner = characterId,
                fullName = characterId,
                firstSeenAt = now,
                lastSeenAt = now,
                updatedAt = 0,
                updatedBy = nil,
                revision = 0,
            }
        end

        return true
    end

    existing.firstSeenAt = existing.firstSeenAt or now
    existing.lastSeenAt = now

    if type(myToons[characterId]) ~= "table" then
        myToons[characterId] = {
            id = characterId,
            owner = characterId,
            fullName = characterId,
            firstSeenAt = existing.firstSeenAt or now,
            lastSeenAt = now,
            updatedAt = 0,
            updatedBy = nil,
            revision = 0,
        }
    else
        if myToons[characterId].deleted then
            myToons[characterId].deleted = false
            myToons[characterId].updatedAt = now
            myToons[characterId].updatedBy = addon.Store:GetPlayerName()
            myToons[characterId].revision = (myToons[characterId].revision or 0) + 1
        end

        myToons[characterId].lastSeenAt = now
        myToons[characterId].firstSeenAt = myToons[characterId].firstSeenAt or existing.firstSeenAt or now
    end

    return false
end

local function collectKnownPlayerCharacterIds()
    local profile = addon.Store:GetProfile()
    local ids = {}

    if type(profile.myCharacters) ~= "table" then
        return ids
    end

    for characterId in pairs(profile.myCharacters) do
        local normalizedId = addon.Store:NormalizeFullName(characterId, GetRealmName() or "Unknown")

        if type(normalizedId) == "string" and normalizedId ~= "" then
            ids[#ids + 1] = normalizedId
        end
    end

    return ids
end

local function collectLocalMyToonIds(primaryId)
    local ids = {}
    local seen = {}

    local function addId(candidateId)
        local normalizedId = addon.Store:NormalizeFullName(candidateId, GetRealmName() or "Unknown")

        if type(normalizedId) == "string" and normalizedId ~= "" and not seen[normalizedId] then
            seen[normalizedId] = true
            ids[#ids + 1] = normalizedId
        end
    end

    addId(primaryId)

    for _, characterId in ipairs(collectKnownPlayerCharacterIds()) do
        addId(characterId)
    end

    return ids
end

local function applySharedProfileFieldsToLocalToons(sourceRecord, values, updatedAt)
    if type(sourceRecord) ~= "table" or not isLocalMyToonId(sourceRecord.id) then
        return false
    end

    if type(values) ~= "table" then
        return false
    end

    local changed = false
    local now = tonumber(updatedAt) or time()
    local updatedBy = addon.Store:GetPlayerName()
    local roster = addon.Store:GetGuild().roster or {}
    local myToons = addon.Store:GetMyToons()

    for _, toonId in ipairs(collectLocalMyToonIds(sourceRecord.id)) do
        local rosterRecord = roster[toonId]

        if type(rosterRecord) == "table" then
            local rosterChanged = false

            for fieldName, fieldValue in pairs(values) do
                if rosterRecord[fieldName] ~= fieldValue then
                    rosterRecord[fieldName] = fieldValue
                    rosterChanged = true
                end
            end

            if rosterChanged then
                rosterRecord.updatedAt = now
                rosterRecord.updatedBy = updatedBy
                rosterRecord.revision = (rosterRecord.revision or 0) + 1
                changed = true
            end
        end

        local myToonRecord = myToons[toonId]

        if type(myToonRecord) == "table" then
            local myToonChanged = false

            for fieldName, fieldValue in pairs(values) do
                if myToonRecord[fieldName] ~= fieldValue then
                    myToonRecord[fieldName] = fieldValue
                    myToonChanged = true
                end
            end

            if myToonChanged then
                myToonRecord.lastSeenAt = now
                myToonRecord.updatedAt = now
                myToonRecord.updatedBy = updatedBy
                myToonRecord.revision = (myToonRecord.revision or 0) + 1
                changed = true
            end
        end
    end

    return changed
end

function addon.RosterService:EnsurePlayerMemberRecord()
    local playerId = addon.Store:GetPlayerName()
    local guildKey = addon.Store:GetGuildKey()
    local className, classFile = UnitClass("player")
    local level = UnitLevel("player") or 0
    local record = self:GetMember(playerId)
    local changed = ensureKnownPlayerCharacter(playerId)
    local previousSyncSignature = getRosterSyncSignature(record)

    if not record then
        record = makeGuildMemberRecord(playerId, guildKey, classFile, className, level, "", true, "")
        addon.Store:GetGuild().roster[playerId] = record
        changed = true
    end

    if guildKey and record.guildName ~= guildKey then
        record.guildName = guildKey
        changed = true
    end

    if record.deleted then
        record.deleted = false
        changed = true
    end

    if record.leftGuild then
        record.leftGuild = false
        changed = true
    end

    if confirmGuildRosterPresence(record) then
        changed = true
    end

    local hadOnlineChange = false

    if not record.online then
        record.online = true
        hadOnlineChange = true
    end

    record.lastSeenAt = time()

    if applyLivePlayerData(record) then
        changed = true
    end

    if changed then
        recordRosterSyncMutation(record, previousSyncSignature)
    end

    return record
end

function addon.RosterService:CaptureGuildRoster()
    local perfScopeName = "roster:capture-guild-roster"
    local perfStartedAt = addon.BeginPerfScope and addon:BeginPerfScope(perfScopeName) or nil
    local ensurePlayerPerfStartedAt = beginRosterPerfScope("roster:capture-ensure-player")
    local shouldTrackDetailedPerf = addon.IsPerfProfilingEnabled and addon:IsPerfProfilingEnabled() or false

    self:EnsurePlayerMemberRecord()
    endRosterPerfScope("roster:capture-ensure-player", ensurePlayerPerfStartedAt)
    addon.Store:EnsureHomeGuild()

    local guildKey = addon.Store:GetGuildKey()

    if not guildKey then
        if perfStartedAt ~= nil and addon.EndPerfScope then
            addon:EndPerfScope(perfScopeName, perfStartedAt)
        end
        return
    end

    local seen = {}
    local roster = addon.Store:GetGuild().roster
    self._guildRosterTrackedMembers = type(self._guildRosterTrackedMembers) == "table" and self._guildRosterTrackedMembers or {}
    local rosterChanged = false
    local maintenanceService = addon.MaintenanceService
    local maintenanceSettings = maintenanceService and maintenanceService.GetSettings and maintenanceService:GetSettings() or nil
    local maintenanceState = maintenanceService and addon.Store:GetGuild().maintenance or nil
    local realmName = GetRealmName() or "Unknown"
    local playerFaction = UnitFactionGroup("player") or "Unknown"
    local capturedAt = time()
    local auditReady = maintenanceService and maintenanceService.BeginRosterAuditCapture and maintenanceService:BeginRosterAuditCapture(guildKey) and true or false
    local trackedLocalCharacters = addon.Store:GetProfile().myCharacters or {}
    local trackOfficerNotes = type(maintenanceSettings) == "table"
        and maintenanceSettings.auditEnabled ~= false
        and maintenanceSettings.auditLogOfficerNotes ~= false
    local officerNoteSignatures = trackOfficerNotes
        and type(maintenanceState) == "table"
        and type(maintenanceState.auditOfficerNoteSignatures) == "table"
        and maintenanceState.auditOfficerNoteSignatures
        or nil
    local seenCount = 0
    local guildMemberCount = GetNumGuildMembers()
    local memberReadTotalMs = 0
    local normalizeNameTotalMs = 0
    local lastSeenTotalMs = 0
    local officerMetaTotalMs = 0
    local syncMutationTotalMs = 0
    local previousGuildTrackedMembers = self._guildRosterTrackedMembers[guildKey]
    local nextGuildTrackedMembers = {}
    local memberPassPerfStartedAt = beginRosterPerfScope("roster:capture-member-pass")

    if type(previousGuildTrackedMembers) ~= "table" then
        previousGuildTrackedMembers = {}

        for fullName, record in pairs(roster) do
            if type(record) == "table"
                and record.guildName == guildKey
                and not record.deleted
                and not record.leftGuild then
                previousGuildTrackedMembers[fullName] = true
            end
        end
    end

    for index = 1, guildMemberCount do
        local readMemberStartedAt = shouldTrackDetailedPerf and getRosterPerfClockMilliseconds() or nil
        local name, rank, _, level, className, _, note, officerNote, online, _, classFileName = GetGuildRosterInfo(index)

        if readMemberStartedAt ~= nil then
            memberReadTotalMs = memberReadTotalMs + math.max(0, getRosterPerfClockMilliseconds() - readMemberStartedAt)
        end

        if name and name ~= "" then
            local normalizeNameStartedAt = shouldTrackDetailedPerf and getRosterPerfClockMilliseconds() or nil
            local normalizedName, cachedCharacterName, cachedRealmName = getCachedRosterNameInfo(self, name, realmName)

            if normalizeNameStartedAt ~= nil then
                normalizeNameTotalMs = normalizeNameTotalMs + math.max(0, getRosterPerfClockMilliseconds() - normalizeNameStartedAt)
            end

            seen[normalizedName] = true
            nextGuildTrackedMembers[normalizedName] = true
            seenCount = seenCount + 1

            local existing = roster[normalizedName]

            if existing then
                local changed = false
                local syncChanged = false
                local previousSyncSignature = nil
                local previousOnline = existing.online and true or false
                local existingLastSeenAt = tonumber(existing.lastSeenAt) or 0
                local existingGuildRosterMissCount = tonumber(existing.guildRosterMissCount) or 0
                local existingNoteCapturedAt = tonumber(existing.noteCapturedAt) or 0
                local characterName = existing.name
                local memberRealmName = existing.realm
                local nextRank = rank or existing.rank
                local nextLevel = level or existing.level
                local nextFaction = existing.faction or playerFaction
                local nextClassName = className or existing.className
                local nextClassFile = classFileName or existing.classFile
                local nextOnline = online and true or false
                local nextNote = note or existing.note
                local liveNoteCapturedAt = note ~= nil and capturedAt or nil
                local lastSeenStartedAt = shouldTrackDetailedPerf and getRosterPerfClockMilliseconds() or nil
                local nextLastSeenAt = resolveExistingRosterLastSeenAt(index, nextOnline, previousOnline, existingLastSeenAt)

                if lastSeenStartedAt ~= nil then
                    lastSeenTotalMs = lastSeenTotalMs + math.max(0, getRosterPerfClockMilliseconds() - lastSeenStartedAt)
                end

                if type(characterName) ~= "string" or characterName == "" or type(memberRealmName) ~= "string" or memberRealmName == "" then
                    characterName = cachedCharacterName
                    memberRealmName = cachedRealmName
                end

                if existing.rank ~= nextRank then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)

                    if auditReady and maintenanceService and maintenanceService.RecordRosterRankChange then
                        maintenanceService:RecordRosterRankChange(normalizedName, existing.rank, nextRank)
                    end

                    existing.rank = nextRank
                    changed = true
                    syncChanged = true
                end

                if existing.level ~= nextLevel then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)
                    existing.level = nextLevel
                    changed = true
                    syncChanged = true
                end

                if characterName and existing.name ~= characterName then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)
                    existing.name = characterName
                    changed = true
                    syncChanged = true
                end

                if memberRealmName and existing.realm ~= memberRealmName then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)
                    existing.realm = memberRealmName
                    changed = true
                    syncChanged = true
                end

                if existing.faction ~= nextFaction then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)
                    existing.faction = nextFaction
                    changed = true
                    syncChanged = true
                end

                if existing.className ~= nextClassName then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)
                    existing.className = nextClassName
                    changed = true
                    syncChanged = true
                end

                if existing.classFile ~= nextClassFile then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)
                    existing.classFile = nextClassFile
                    changed = true
                    syncChanged = true
                end

                if existing.online ~= nextOnline then
                    existing.online = nextOnline
                    changed = true
                end

                if existing.leftGuild then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)

                    if auditReady and maintenanceService and maintenanceService.RecordRosterJoin then
                        maintenanceService:RecordRosterJoin(normalizedName)
                    end

                    existing.leftGuild = false
                    changed = true
                    syncChanged = true
                end

                if confirmGuildRosterPresence(existing, capturedAt) then
                    changed = true
                end

                if existingGuildRosterMissCount > 0 then
                    existing.guildRosterMissCount = 0
                    existing.lastGuildRosterMissingAt = nil
                    changed = true
                end

                if existing.note ~= nextNote then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)

                    if existingNoteCapturedAt > 0 and auditReady and maintenanceService and maintenanceService.RecordRosterNoteChange then
                        maintenanceService:RecordRosterNoteChange(normalizedName, existing.note, nextNote)
                    end

                    existing.note = nextNote
                    existing.noteCapturedAt = liveNoteCapturedAt or existingNoteCapturedAt
                    changed = true
                    syncChanged = true
                elseif liveNoteCapturedAt and existingNoteCapturedAt <= 0 then
                    existing.noteCapturedAt = liveNoteCapturedAt
                    changed = true
                end

                if existing.guildName ~= guildKey then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)
                    existing.guildName = guildKey
                    changed = true
                    syncChanged = true
                end

                local officerMetaStartedAt = shouldTrackDetailedPerf and getRosterPerfClockMilliseconds() or nil

                if trackedLocalCharacters[normalizedName] ~= nil then
                    self:CaptureJoinDateCandidateFromOfficerNote(normalizedName, officerNote, existing.guildJoinDate)
                end

                if officerNoteSignatures and maintenanceService and maintenanceService.TrackRosterOfficerNote then
                    local officerSourceValue = trimString(officerNote)
                    local previousOfficerState = officerNoteSignatures[normalizedName]

                    if type(previousOfficerState) ~= "table" or previousOfficerState.sourceValue ~= officerSourceValue then
                        maintenanceService:TrackRosterOfficerNote(normalizedName, officerNote, auditReady, previousOfficerState, officerSourceValue)
                    end
                end

                if officerMetaStartedAt ~= nil then
                    officerMetaTotalMs = officerMetaTotalMs + math.max(0, getRosterPerfClockMilliseconds() - officerMetaStartedAt)
                end

                if scrubSensitiveRosterFields(existing) then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)
                    changed = true
                    syncChanged = true
                end

                if existing.deleted then
                    previousSyncSignature = ensureRosterSyncSignature(existing, previousSyncSignature)
                    existing.deleted = false
                    changed = true
                    syncChanged = true
                end

                if nextLastSeenAt and nextLastSeenAt > 0 and existingLastSeenAt ~= nextLastSeenAt then
                    existing.lastSeenAt = nextLastSeenAt
                    changed = true
                end

                if syncChanged then
                    local syncMutationStartedAt = shouldTrackDetailedPerf and getRosterPerfClockMilliseconds() or nil
                    recordRosterSyncMutation(existing, previousSyncSignature, capturedAt)

                    if syncMutationStartedAt ~= nil then
                        syncMutationTotalMs = syncMutationTotalMs + math.max(0, getRosterPerfClockMilliseconds() - syncMutationStartedAt)
                    end

                    rosterChanged = true
                end
            else
                local newRecord = makeGuildMemberRecord(
                    normalizedName,
                    guildKey,
                    classFileName,
                    className,
                    level,
                    rank,
                    online,
                    note,
                    estimateLastSeenAtFromGuildRoster(index, online and true or false, capturedAt)
                )
                roster[normalizedName] = newRecord
                local officerMetaStartedAt = shouldTrackDetailedPerf and getRosterPerfClockMilliseconds() or nil
                newRecord.noteCapturedAt = capturedAt

                if trackedLocalCharacters[normalizedName] ~= nil then
                    self:CaptureJoinDateCandidateFromOfficerNote(normalizedName, officerNote, newRecord.guildJoinDate)
                end

                if officerNoteSignatures and maintenanceService and maintenanceService.TrackRosterOfficerNote then
                    local officerSourceValue = trimString(officerNote)
                    local previousOfficerState = officerNoteSignatures[normalizedName]

                    if type(previousOfficerState) ~= "table" or previousOfficerState.sourceValue ~= officerSourceValue then
                        maintenanceService:TrackRosterOfficerNote(normalizedName, officerNote, auditReady, previousOfficerState, officerSourceValue)
                    end
                end

                if officerMetaStartedAt ~= nil then
                    officerMetaTotalMs = officerMetaTotalMs + math.max(0, getRosterPerfClockMilliseconds() - officerMetaStartedAt)
                end

                confirmGuildRosterPresence(newRecord, capturedAt)
                local syncMutationStartedAt = shouldTrackDetailedPerf and getRosterPerfClockMilliseconds() or nil
                recordRosterSyncMutation(newRecord, nil, newRecord.updatedAt)

                if syncMutationStartedAt ~= nil then
                    syncMutationTotalMs = syncMutationTotalMs + math.max(0, getRosterPerfClockMilliseconds() - syncMutationStartedAt)
                end

                if auditReady and maintenanceService and maintenanceService.RecordRosterJoin then
                    maintenanceService:RecordRosterJoin(normalizedName)
                end

                rosterChanged = true
            end
        end
    end

    endRosterPerfScope("roster:capture-member-pass", memberPassPerfStartedAt)
    recordRosterPerfDuration("roster:capture-read-member-info", memberReadTotalMs, capturedAt)
    recordRosterPerfDuration("roster:capture-normalize-member-name", normalizeNameTotalMs, capturedAt)
    recordRosterPerfDuration("roster:capture-last-seen-resolution", lastSeenTotalMs, capturedAt)
    recordRosterPerfDuration("roster:capture-officer-meta", officerMetaTotalMs, capturedAt)

    local missingPassPerfStartedAt = beginRosterPerfScope("roster:capture-missing-pass")

    for fullName in pairs(previousGuildTrackedMembers) do
        local record = roster[fullName]

        if type(record) == "table" and record.guildName == guildKey and not record.deleted and not seen[fullName] then
            if not record.guildRosterConfirmed then
                record.guildRosterMissCount = 0
                record.lastGuildRosterMissingAt = nil
                nextGuildTrackedMembers[fullName] = true
            else
                if record.online then
                    record.online = false
                end

                if not record.leftGuild then
                    local syncChanged = false
                    local previousSyncSignature = nil
                    local previousMissCount = tonumber(record.guildRosterMissCount) or 0
                    local missCount = previousMissCount + 1

                    if previousMissCount ~= missCount then
                        record.guildRosterMissCount = missCount
                        record.lastGuildRosterMissingAt = capturedAt
                    end

                    if missCount >= 2 then
                        previousSyncSignature = ensureRosterSyncSignature(record, previousSyncSignature)
                        record.leftGuild = true
                        syncChanged = true

                        if auditReady and maintenanceService and maintenanceService.RecordRosterDeparture then
                            maintenanceService:RecordRosterDeparture(fullName)
                        end
                    end

                    if syncChanged then
                        local syncMutationStartedAt = shouldTrackDetailedPerf and getRosterPerfClockMilliseconds() or nil
                        recordRosterSyncMutation(record, previousSyncSignature, capturedAt)

                        if syncMutationStartedAt ~= nil then
                            syncMutationTotalMs = syncMutationTotalMs + math.max(0, getRosterPerfClockMilliseconds() - syncMutationStartedAt)
                        end

                        rosterChanged = true
                    end

                    if not record.leftGuild then
                        nextGuildTrackedMembers[fullName] = true
                    end
                end
            end
        end
    end

    endRosterPerfScope("roster:capture-missing-pass", missingPassPerfStartedAt)
    recordRosterPerfDuration("roster:capture-sync-mutation-total", syncMutationTotalMs, capturedAt)

    local auditCompletePerfStartedAt = beginRosterPerfScope("roster:capture-audit-complete")
    if maintenanceService and maintenanceService.CompleteRosterAuditCapture then
        maintenanceService:CompleteRosterAuditCapture(guildKey, seenCount)
    end
    endRosterPerfScope("roster:capture-audit-complete", auditCompletePerfStartedAt)

    if rosterChanged then
        addon.Store:GetSync().tableMeta.roster.updatedAt = capturedAt
    end

    self._guildRosterTrackedMembers[guildKey] = nextGuildTrackedMembers

    if perfStartedAt ~= nil and addon.EndPerfScope then
        addon:EndPerfScope(perfScopeName, perfStartedAt)
    end
end

function addon.RosterService:ListVisibleMembers()
    local visibleGuilds = {}

    for _, guildKey in ipairs(addon.FederationService:GetAllGuildKeys()) do
        visibleGuilds[guildKey] = true
    end

    local members = addon.Repositories:List("roster", function(record)
        return not record.deleted and not record.leftGuild and visibleGuilds[record.guildName]
    end)

    for index, member in ipairs(members) do
        members[index] = applyVisibleOnlineState(overlayMyToonData(member, addon.Store:GetMyToons()[member.id]))
    end

    return members
end

function addon.RosterService:ListCurrentGuildMembersRaw()
    local currentGuildKey = addon.Store:GetGuildKey() or addon.Store:EnsureHomeGuild()

    if trimString(currentGuildKey) == "" then
        return {}
    end

    local members = addon.Repositories:List("roster", function(record)
        return not record.deleted and not record.leftGuild and record.guildName == currentGuildKey
    end)

    for index, member in ipairs(members) do
        members[index] = applyVisibleOnlineState(member)
    end

    return members
end

function addon.RosterService:GetCounts()
    local counts = {
        total = 0,
        online = 0,
        byGuild = {},
    }

    for _, record in ipairs(self:ListVisibleMembers()) do
        counts.total = counts.total + 1

        if record.online then
            counts.online = counts.online + 1
        end

        counts.byGuild[record.guildName] = (counts.byGuild[record.guildName] or 0) + 1
    end

    return counts
end

function addon.RosterService:GetMember(memberId)
    return addon.Repositories:Get("roster", addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown"))
end

function addon.RosterService:GetMyToonRecord(memberId)
    local normalizedId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    local record = addon.Store:GetMyToons()[normalizedId]

    if scrubSensitiveMyToonFields(record) then
        addon.Store:GetSync().tableMeta.mytoons.updatedAt = time()
    end

    return record
end

function addon.RosterService:GetJoinDateCandidate(memberId)
    local normalizedId, entry = getLocalMyCharacterMeta(memberId, false)
    local candidate = type(entry) == "table" and entry.joinDateCandidate or nil

    if type(candidate) ~= "table" then
        return nil
    end

    candidate.toonId = candidate.toonId or normalizedId
    return candidate
end

function addon.RosterService:ClearJoinDateCandidate(memberId)
    return clearStoredJoinDateCandidate(memberId)
end

function addon.RosterService:CaptureJoinDateCandidate(memberId, dateValue, source)
    local normalizedId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

    if not isLocalMyToonId(normalizedId) then
        return false
    end

    local record = self:GetMember(normalizedId)
    local normalizedDate = normalizeDate(dateValue)

    if normalizedDate == nil then
        return false
    end

    if type(record) == "table" and record.guildJoinDate == normalizedDate then
        clearStoredJoinDateCandidate(normalizedId)
        return false
    end

    return storeJoinDateCandidate(normalizedId, normalizedDate, source or JOIN_DATE_CANDIDATE_SOURCE_OFFICER_NOTE)
end

function addon.RosterService:CaptureJoinDateCandidateFromOfficerNote(memberId, officerNote, existingJoinDate)
    local knownCharacters = addon.Store:GetProfile().myCharacters or {}
    local normalizedId = knownCharacters[memberId] ~= nil and memberId or addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

    if knownCharacters[normalizedId] == nil then
        return false
    end

    local parsedDate = parseJoinDateFromOfficerNoteText(officerNote)

    if not parsedDate then
        return false
    end

    if normalizeDate(existingJoinDate) == parsedDate then
        clearStoredJoinDateCandidate(normalizedId)
        return false
    end

    return storeJoinDateCandidate(normalizedId, parsedDate, JOIN_DATE_CANDIDATE_SOURCE_OFFICER_NOTE)
end

function addon.RosterService:ApplyJoinDateCandidate(memberId)
    local normalizedId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    local candidate = self:GetJoinDateCandidate(normalizedId)
    local record = self:GetMember(normalizedId)
    local now = time()

    if type(candidate) ~= "table" or normalizeDate(candidate.value) == nil then
        return nil, "no join date candidate"
    end

    if not record then
        return nil, "toon not found"
    end

    local normalizedDate = normalizeDate(candidate.value)

    if normalizedDate == nil then
        return nil, "invalid join date candidate"
    end

    local previousSyncSignature = getRosterSyncSignature(record)
    record.guildJoinDate = normalizedDate
    record.updatedAt = now
    record.updatedBy = addon.Store:GetPlayerName()
    record.revision = (record.revision or 0) + 1
    addon.Store:GetSync().tableMeta.roster.updatedAt = now

    if isLocalMyToonId(normalizedId) then
        local myToons = addon.Store:GetMyToons()
        local myToonRecord = myToons[normalizedId]

        if type(myToonRecord) == "table" and not isDeletedMyToonRecord(myToonRecord) then
            myToonRecord.guildJoinDate = normalizedDate
            myToonRecord.lastSeenAt = now
            myToonRecord.updatedAt = now
            myToonRecord.updatedBy = addon.Store:GetPlayerName()
            myToonRecord.revision = (myToonRecord.revision or 0) + 1
            addon.Store:GetSync().tableMeta.mytoons.updatedAt = now
        end
    end

    clearStoredJoinDateCandidate(normalizedId)

    if previousSyncSignature ~= getRosterSyncSignature(record)
        and type(addon.Reconciler) == "table"
        and type(addon.Reconciler.AppendRosterJournalRecord) == "function" then
        addon.Reconciler:AppendRosterJournalRecord(record)
    end

    return record
end

function addon.RosterService:RefreshGuildJoinDatesFromNews(requestIfUnavailable)
    local guild = addon.Store:GetGuild()
    local roster = type(guild) == "table" and type(guild.roster) == "table" and guild.roster or nil

    if type(roster) ~= "table" then
        return false
    end

    local latestJoinDates = buildGuildNewsJoinDateLookup(nil, requestIfUnavailable)

    if next(latestJoinDates) == nil then
        return false
    end

    local changed = false

    for memberId, joinDate in pairs(latestJoinDates) do
        if setMemberGuildJoinDate(memberId, joinDate) then
            changed = true
        end
    end

    return changed
end

function addon.RosterService:GetGuildNewsJoinDateLookup(requestIfUnavailable, memberIdSet)
    return buildGuildNewsJoinDateLookup(memberIdSet, requestIfUnavailable)
end

function addon.RosterService:HandleGuildNewsUpdate()
    return self:RefreshGuildJoinDatesFromNews(false)
end

function addon.RosterService:GetMyToonMember(memberId)
    local myToonRecord = self:GetMyToonRecord(memberId)

    if isDeletedMyToonRecord(myToonRecord) then
        return nil
    end

    local record = self:GetMember(memberId)

    if not record then
        return nil
    end

    local displayRecord = cloneValue(record)
    return overlayMyToonData(displayRecord, myToonRecord)
end

function addon.RosterService:ApplySyncedMyToonsToRoster()
    self:PurgeDeletedMyToons()
    self:ScrubSensitiveMyToons()

    local roster = addon.Store:GetGuild().roster

    for memberId, myToonRecord in pairs(addon.Store:GetMyToons()) do
        local rosterRecord = roster[memberId]

        if rosterRecord and not isDeletedMyToonRecord(myToonRecord) then
            applyMyToonFieldsToRosterRecord(rosterRecord, myToonRecord)
        end
    end
end

function addon.RosterService:GetFirstVisibleMemberId()
    local members = self:ListVisibleMembers()

    if #members == 0 then
        return nil
    end

    return members[1].id
end

function addon.RosterService:GetPlayerMember()
    return self:EnsurePlayerMemberRecord()
end

function addon.RosterService:RefreshLivePlayerMember()
    local playerMember = self:EnsurePlayerMemberRecord()
    local previousSyncSignature = getRosterSyncSignature(playerMember)

    if applyLivePlayerData(playerMember) then
        playerMember.updatedAt = time()
        playerMember.updatedBy = addon.Store:GetPlayerName()
        playerMember.revision = (playerMember.revision or 0) + 1
        addon.Store:GetSync().tableMeta.roster.updatedAt = playerMember.updatedAt

        if previousSyncSignature ~= getRosterSyncSignature(playerMember)
            and type(addon.Reconciler) == "table"
            and type(addon.Reconciler.AppendRosterJournalRecord) == "function" then
            addon.Reconciler:AppendRosterJournalRecord(playerMember)
        end
    end

    return playerMember
end

function addon.RosterService:GetPlayerMainCharacter()
    local cachedMain = getCachedPlayerMainCharacter()

    if trimString(cachedMain) ~= "" then
        return cachedMain
    end

    return addon.Store:GetPlayerName()
end

function addon.RosterService:IsMyToon(memberOrId)
    local member = type(memberOrId) == "table" and memberOrId or self:GetMember(memberOrId)

    if not member or member.deleted then
        return false
    end

    local myToonRecord = self:GetMyToonRecord(member.id or memberOrId)

    if isDeletedMyToonRecord(myToonRecord) then
        return false
    end

    local knownCharacters = addon.Store:GetProfile().myCharacters or {}

    if knownCharacters[member.id] or knownCharacters[member.fullName] then
        return true
    end

    local playerName = addon.Store:GetPlayerName()
    local playerMain = getCachedPlayerMainCharacter()

    return member.id == playerName
        or member.fullName == playerName
        or (member.mainCharacter and member.mainCharacter == playerMain)
end

function addon.RosterService:ListMyToons()
    local myToons = {}
    local seen = {}
    local knownCharacters = addon.Store:GetProfile().myCharacters or {}
    local playerName = addon.Store:GetPlayerName()
    local playerMain = getCachedPlayerMainCharacter()

    self:EnsurePlayerMemberRecord()

    for _, characterId in ipairs(collectKnownPlayerCharacterIds()) do
        local member = self:GetMyToonMember(characterId)

        if member and not member.deleted and not seen[member.id] then
            seen[member.id] = true
            myToons[#myToons + 1] = member
        end
    end

    for _, member in ipairs(self:ListVisibleMembers()) do
        local memberId = member.id or member.fullName or ""
        local myToonRecord = self:GetMyToonRecord(memberId)
        local isKnownCharacter = memberId ~= "" and (knownCharacters[memberId] or knownCharacters[member.fullName or ""])
        local isPlayerCharacter = memberId == playerName
            or member.fullName == playerName
            or (member.mainCharacter and member.mainCharacter == playerMain)

        if memberId ~= "" and not isDeletedMyToonRecord(myToonRecord) and (isKnownCharacter or isPlayerCharacter) and not seen[memberId] then
            seen[memberId] = true
            myToons[#myToons + 1] = member
        end
    end

    table.sort(myToons, function(left, right)
        return (left.fullName or "") < (right.fullName or "")
    end)

    return myToons
end

function addon.RosterService:DeleteMyToon(memberId)
    local normalizedId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    local playerId = addon.Store:GetPlayerName()

    if normalizedId == "" then
        return nil, "invalid toon"
    end

    if normalizedId == playerId then
        return nil, "cannot delete the character you are currently logged into"
    end

    local profile = addon.Store:GetProfile()
    local myToons = addon.Store:GetMyToons()
    local existingMyToon = myToons[normalizedId]
    local rosterRecord = self:GetMember(normalizedId)
    local wasKnownLocally = isLocalMyToonId(normalizedId)

    if not wasKnownLocally and not existingMyToon then
        return nil, "toon is not in your My Toons list"
    end

    if type(profile.myCharacters) ~= "table" then
        profile.myCharacters = {}
    end

    profile.myCharacters[normalizedId] = nil

    local now = time()
    local tombstone = existingMyToon or {
        id = normalizedId,
        owner = normalizedId,
        fullName = normalizedId,
        firstSeenAt = now,
    }

    tombstone.id = normalizedId
    tombstone.owner = tombstone.owner or normalizedId
    tombstone.fullName = tombstone.fullName or (rosterRecord and rosterRecord.fullName) or normalizedId
    tombstone.guildName = tombstone.guildName or (rosterRecord and rosterRecord.guildName) or addon.Store:GetDisplayGuildKey()
    tombstone.realm = tombstone.realm or (rosterRecord and rosterRecord.realm) or select(2, addon.Store:SplitFullName(normalizedId, GetRealmName() or "Unknown"))
    tombstone.classFile = tombstone.classFile or (rosterRecord and rosterRecord.classFile) or nil
    tombstone.className = tombstone.className or (rosterRecord and rosterRecord.className) or nil
    tombstone.faction = tombstone.faction or (rosterRecord and rosterRecord.faction) or nil
    tombstone.level = tombstone.level or (rosterRecord and rosterRecord.level) or 0
    tombstone.firstSeenAt = tombstone.firstSeenAt or now
    tombstone.lastSeenAt = now
    tombstone.deleted = true

    for _, fieldName in ipairs(MYTOON_SYNC_FIELDS) do
        tombstone[fieldName] = nil
    end

    tombstone.updatedAt = now
    tombstone.updatedBy = addon.Store:GetPlayerName()
    tombstone.revision = (tombstone.revision or 0) + 1

    myToons[normalizedId] = tombstone
    addon.Store:GetSync().tableMeta.mytoons.updatedAt = now

    return tombstone
end

function addon.RosterService:PurgeDeletedMyToons()
    local myToons = addon.Store:GetMyToons()
    local profile = addon.Store:GetProfile()
    local cutoff = getMyToonTombstonePurgeCutoff()
    local purged = 0

    if type(profile.myCharacters) ~= "table" then
        profile.myCharacters = {}
    end

    for memberId, record in pairs(myToons) do
        if type(record) == "table" and record.deleted and (record.updatedAt or 0) > 0 and (record.updatedAt or 0) <= cutoff then
            myToons[memberId] = nil
            profile.myCharacters[memberId] = nil
            purged = purged + 1
        end
    end

    if purged > 0 then
        addon.Store:GetSync().tableMeta.mytoons.updatedAt = time()
    end

    return purged
end

function addon.RosterService:ScrubSensitiveMyToons()
    local myToons = addon.Store:GetMyToons()
    local changed = false

    for _, record in pairs(myToons) do
        if scrubSensitiveMyToonFields(record) then
            changed = true
        end
    end

    if changed then
        addon.Store:GetSync().tableMeta.mytoons.updatedAt = time()
    end

    return changed
end

function addon.RosterService:ScrubSensitiveRosterData()
    local roster = addon.Store:GetGuild().roster
    local changed = false

    for _, record in pairs(roster) do
        if scrubSensitiveRosterFields(record) then
            changed = true
        end
    end

    if changed then
        addon.Store:GetSync().tableMeta.roster.updatedAt = time()
    end

    return changed
end

function addon.RosterService:GetFirstMyToonId()
    local myToons = self:ListMyToons()

    if #myToons == 0 then
        return nil
    end

    return myToons[1].id
end

function addon.RosterService:GetRoleOptions(memberOrClassFile)
    local classFileName = type(memberOrClassFile) == "table" and memberOrClassFile.classFile or memberOrClassFile
    return getRoleOptionsForClass(classFileName)
end

function addon.RosterService:GetProfileOptions(memberOrClassFile)
    local classFileName = type(memberOrClassFile) == "table" and memberOrClassFile.classFile or memberOrClassFile
    return getProfileOptionsForClass(classFileName)
end

function addon.RosterService:UpdateMember(memberId, payload)
    local record = self:GetMember(memberId)

    if not record then
        return nil
    end

    local allowedRoles = self:GetRoleOptions(record)
    local allowedProfiles = self:GetProfileOptions(record)
    local selectedSecondaryRoles = {}
    local isCurrentPlayerRecord = record.id == addon.Store:GetPlayerName()
    local isLocalToon = isLocalMyToonId(record.id)
    local now = time()
    local previousSyncSignature = getRosterSyncSignature(record)

    if isCurrentPlayerRecord then
        applyLivePlayerData(record)
    end

    for _, role in ipairs(normalizeList(payload.secondaryRoles)) do
        local normalizedRole = string.lower(role)

        if containsValue(allowedRoles, normalizedRole) and not containsValue(selectedSecondaryRoles, normalizedRole) then
            selectedSecondaryRoles[#selectedSecondaryRoles + 1] = normalizedRole
        end
    end

    if not isCurrentPlayerRecord then
        record.spec = payload.spec ~= "" and payload.spec or nil
        record.itemLevel = tonumber(payload.itemLevel) or 0
    end
    record.primaryRole = normalizeChoice(payload.primaryRole or record.primaryRole, allowedRoles, record.primaryRole or allowedRoles[1] or "dps")
    record.meleeOrRanged = normalizeChoice(payload.meleeOrRanged or record.meleeOrRanged, allowedProfiles, record.meleeOrRanged or allowedProfiles[1] or "unknown")
    record.mainCharacter = payload.mainCharacter ~= "" and addon.Store:NormalizeFullName(payload.mainCharacter, GetRealmName() or "Unknown") or record.fullName
    record.guildJoinDate = normalizeDate(payload.guildJoinDate)
    record.birthdate = normalizeDate(payload.birthdate)
    record.battleTag = trimString(payload.battleTag) ~= "" and trimString(payload.battleTag) or nil
    record.note = payload.note or ""
    record.professions = normalizeList(payload.professions)
    record.interests = normalizeList(payload.interests)
    record.secondaryRoles = selectedSecondaryRoles
    record.updatedAt = now
    record.updatedBy = addon.Store:GetPlayerName()
    record.revision = (record.revision or 0) + 1

    local propagatedSharedFields = false

    if isLocalToon and (payload.birthdate ~= nil or payload.battleTag ~= nil) then
        propagatedSharedFields = applySharedProfileFieldsToLocalToons(record, {
            battleTag = record.battleTag,
            birthdate = record.birthdate,
        }, now)
    end

    addon.Store:GetSync().tableMeta.roster.updatedAt = record.updatedAt

    if isLocalToon then
        local myToons = addon.Store:GetMyToons()
        local myToonRecord = myToons[record.id] or {
            id = record.id,
            owner = record.id,
            fullName = record.fullName,
            firstSeenAt = now,
        }

        myToonRecord.id = record.id
        myToonRecord.owner = myToonRecord.owner or record.id
        myToonRecord.fullName = record.fullName
        myToonRecord.guildName = addon.Store:GetDisplayGuildKey() or record.guildName
        myToonRecord.realm = record.realm
        myToonRecord.classFile = record.classFile
        myToonRecord.className = record.className
        myToonRecord.faction = record.faction
        myToonRecord.level = record.level
        myToonRecord.officerNote = nil

        for _, fieldName in ipairs(MYTOON_SYNC_FIELDS) do
            myToonRecord[fieldName] = cloneValue(record[fieldName])
        end

        myToonRecord.lastSeenAt = now
        myToonRecord.updatedAt = now
        myToonRecord.updatedBy = addon.Store:GetPlayerName()
        myToonRecord.revision = (myToonRecord.revision or 0) + 1

        myToons[record.id] = myToonRecord
        addon.Store:GetSync().tableMeta.mytoons.updatedAt = now

        if payload.guildJoinDate ~= nil then
            clearStoredJoinDateCandidate(record.id)
        end
    elseif propagatedSharedFields then
        addon.Store:GetSync().tableMeta.mytoons.updatedAt = now
    end

    if previousSyncSignature ~= getRosterSyncSignature(record)
        and type(addon.Reconciler) == "table"
        and type(addon.Reconciler.AppendRosterJournalRecord) == "function" then
        addon.Reconciler:AppendRosterJournalRecord(record)
    end

    return record
end
