GuildHelper = GuildHelper or {}

local addon = GuildHelper

addon.GroupsService = addon.GroupsService or {}
addon.GroupsService.runtime = addon.GroupsService.runtime or {}

local runtime = addon.GroupsService.runtime
local GROUP_REMINDER_DELAY_SECONDS = 300

local ACTIVITY_TEMPLATES = {
    {
        key = "heroic-raid",
        label = "Heroic Raid",
        defaultName = "Tuesday Heroic Raid",
        activityType = "raid",
        difficulty = "Heroic",
        minItemLevel = 0,
        requirements = { tanks = 2, healers = 4, melee = 5, ranged = 7 },
    },
    {
        key = "mythic-raid",
        label = "Mythic Raid",
        defaultName = "Mythic Raid Progression",
        activityType = "raid",
        difficulty = "Mythic",
        minItemLevel = 0,
        requirements = { tanks = 2, healers = 4, melee = 5, ranged = 7 },
    },
    {
        key = "normal-raid",
        label = "Normal Raid",
        defaultName = "Normal Raid",
        activityType = "raid",
        difficulty = "Normal",
        minItemLevel = 0,
        requirements = { tanks = 2, healers = 3, melee = 4, ranged = 6 },
    },
    {
        key = "mythic-plus-2",
        label = "Mythic+2",
        defaultName = "Mythic+2 Run",
        activityType = "party",
        difficulty = "Mythic+2",
        minItemLevel = 0,
        requirements = { tanks = 1, healers = 1, melee = 1, ranged = 2 },
    },
    {
        key = "mythic-plus-6",
        label = "Mythic+6",
        defaultName = "Mythic+6 Run",
        activityType = "party",
        difficulty = "Mythic+6",
        minItemLevel = 0,
        requirements = { tanks = 1, healers = 1, melee = 1, ranged = 2 },
    },
    {
        key = "mythic-plus-10",
        label = "Mythic+10",
        defaultName = "Mythic+10 Push",
        activityType = "party",
        difficulty = "Mythic+10",
        minItemLevel = 0,
        requirements = { tanks = 1, healers = 1, melee = 1, ranged = 2 },
    },
}

local WEEKDAY_LABELS = {
    [1] = "Sun",
    [2] = "Mon",
    [3] = "Tue",
    [4] = "Wed",
    [5] = "Thu",
    [6] = "Fri",
    [7] = "Sat",
}

local function inviteUnit(unitName)
    if C_PartyInfo and C_PartyInfo.InviteUnit then
        return C_PartyInfo.InviteUnit(unitName)
    end

    if InviteUnit then
        return InviteUnit(unitName)
    end

    return nil
end

local function inviteBattleNetFriend(battleTag)
    local normalized = trimString(battleTag)

    if normalized == "" or type(BNInviteFriend) ~= "function" then
        return false
    end

    local ok = pcall(BNInviteFriend, normalized)
    return ok and true or false
end

local function canEditOfficerNote()
    if C_GuildInfo and C_GuildInfo.CanEditOfficerNote then
        return C_GuildInfo.CanEditOfficerNote()
    end

    if CanEditOfficerNote then
        return CanEditOfficerNote()
    end

    return false
end

local function isInInstanceGroup()
    return LE_PARTY_CATEGORY_INSTANCE and IsInGroup and IsInGroup(LE_PARTY_CATEGORY_INSTANCE) and true or false
end

local function normalizeRoleLabel(role)
    local roleMap = {
        DAMAGER = "DPS",
        TANK = "Tank",
        HEALER = "Healer",
        dps = "DPS",
        tank = "Tank",
        healer = "Healer",
    }

    return roleMap[role] or role or "?"
end

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

local function isPlaceholderMemberId(value)
    local normalized = string.lower(trimString(value))
    return normalized == "" or normalized == "unknown" or normalized == "unknown-unknown"
end

local function normalizeWhisperTarget(target, fallbackRealm)
    local resolvedFallbackRealm = fallbackRealm or GetRealmName() or "Unknown"
    local candidate = trimString(target)

    if isPlaceholderMemberId(candidate) then
        return nil
    end

    if candidate:find("%s") and not candidate:find("%-") then
        local characterName, realmName = candidate:match("^(%S+)%s+(.+)$")

        if characterName and realmName then
            candidate = string.format("%s-%s", trimString(characterName), trimString(realmName):gsub("%s+", ""))
        end
    end

    local characterName, realmName = candidate:match("^([^-]+)%-(.+)$")

    if characterName and realmName then
        candidate = string.format("%s-%s", trimString(characterName), trimString(realmName):gsub("%s+", ""))
    end

    candidate = addon.Store:NormalizeFullName(candidate, resolvedFallbackRealm)

    if isPlaceholderMemberId(candidate) then
        return nil
    end

    local resolvedName = trimString(candidate:match("^([^-]+)%-(.+)$") or candidate)

    if isPlaceholderMemberId(resolvedName) then
        return nil
    end

    return candidate
end

local function getReminderWhisperTarget(memberRef)
    local member = type(memberRef) == "table" and memberRef or addon.RosterService:GetMember(memberRef)

    if type(member) == "table" then
        local directTarget = normalizeWhisperTarget(member.id or member.fullName, member.realm or GetRealmName() or "Unknown")

        if directTarget then
            return directTarget
        end

        local characterName = trimString(member.name)
        local realmName = trimString(addon.Store:NormalizeRealmName(member.realm, GetRealmName() or "Unknown") or "")

        if characterName ~= "" then
            if realmName ~= "" then
                return normalizeWhisperTarget(characterName .. "-" .. realmName, realmName)
            end

            return normalizeWhisperTarget(characterName, GetRealmName() or "Unknown")
        end

        return normalizeWhisperTarget(member.id or member.fullName, member.realm or GetRealmName() or "Unknown")
    end

    return normalizeWhisperTarget(memberRef, GetRealmName() or "Unknown")
end

local function getOnlineStateMemberId(member, fallbackMemberId)
    if type(member) ~= "table" then
        return nil
    end

    local candidate = trimString(member.id or member.fullName or fallbackMemberId)

    if not isPlaceholderMemberId(candidate) then
        return candidate
    end

    local characterName = trimString(member.name)

    if characterName == "" then
        return nil
    end

    return normalizeWhisperTarget(characterName, member.realm or GetRealmName() or "Unknown")
end

local function getReminderTrackedMemberId(memberRef)
    local memberId = addon.Store:NormalizeFullName(type(memberRef) == "table" and (memberRef.memberId or memberRef.id or memberRef.fullName) or memberRef, GetRealmName() or "Unknown")

    if memberId == "" or isPlaceholderMemberId(memberId) then
        return nil
    end

    return memberId
end

local getGroupTable
local getSignupList
local normalizeGroupRecord
local getTodayOccurrences

local function buildReminderTrackedMembersCacheKey(currentGuildKey)
    local groupsUpdatedAt = addon.Store:GetSync().tableMeta.groups and addon.Store:GetSync().tableMeta.groups.updatedAt or 0
    local todayKey = date("%Y-%m-%d")

    return table.concat({ tostring(currentGuildKey or ""), tostring(groupsUpdatedAt or 0), tostring(todayKey or "") }, "|")
end

local function getReminderTrackedMemberIds(service, currentGuildKey)
    runtime.reminderTrackedMembersCache = type(runtime.reminderTrackedMembersCache) == "table" and runtime.reminderTrackedMembersCache or nil
    local cacheKey = buildReminderTrackedMembersCacheKey(currentGuildKey)

    if runtime.reminderTrackedMembersCache and runtime.reminderTrackedMembersCache.key == cacheKey then
        return runtime.reminderTrackedMembersCache.memberIds or {}
    end

    local trackedMemberIds = {}

    for _, record in pairs(getGroupTable()) do
        local groupRecord = normalizeGroupRecord(record)

        if groupRecord and not groupRecord.deleted and service:CanManageGroup(groupRecord) then
            for _, occurrenceStartAt in ipairs(getTodayOccurrences(groupRecord)) do
                local memberList = select(1, getSignupList(groupRecord, occurrenceStartAt, false)) or groupRecord.members or {}

                for _, entry in ipairs(memberList) do
                    local memberId = getReminderTrackedMemberId(entry)

                    if memberId then
                        trackedMemberIds[memberId] = true
                    end
                end
            end
        end
    end

    runtime.reminderTrackedMembersCache = {
        key = cacheKey,
        memberIds = trackedMemberIds,
    }

    return trackedMemberIds
end

local function sendReminderWhisper(messageText, memberRef)
    if trimString(messageText) == "" or not SendChatMessage then
        return false
    end

    local whisperTarget = getReminderWhisperTarget(memberRef)

    if not whisperTarget then
        return false
    end

    SendChatMessage(messageText, "WHISPER", nil, whisperTarget)
    return true
end

local normalizeMemberEntry

local function cloneList(source)
    local copy = {}

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

    return copy
end

local function cloneMemberList(source)
    local copy = {}

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

    return copy
end

local function cloneRequirements(source)
    return {
        tanks = tonumber(source and source.tanks) or 0,
        healers = tonumber(source and source.healers) or 0,
        melee = tonumber(source and source.melee) or 0,
        ranged = tonumber(source and source.ranged) or 0,
        dps = tonumber(source and source.dps) or 0,
    }
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 normalizeRoleKey(role)
    local normalized = string.lower(trimString(role))

    if normalized == "damager" then
        return "dps"
    end

    if normalized == "tank" or normalized == "healer" or normalized == "dps" then
        return normalized
    end

    return nil
end

local function normalizeProfileKey(profile)
    local normalized = string.lower(trimString(profile))

    if normalized == "melee" or normalized == "ranged" or normalized == "hybrid" then
        return normalized
    end

    return "unknown"
end

local function normalizeActivityType(value)
    return string.lower(trimString(value)) == "raid" and "raid" or "party"
end

local function getDefaultRequirements(activityType)
    local rules = addon.Store:GetGuild().needs.rules[normalizeActivityType(activityType)] or addon.Store:GetGuild().needs.rules.party
    return cloneRequirements(rules)
end

local function normalizeRequirements(source, activityType)
    local defaults = getDefaultRequirements(activityType)
    local requirements = cloneRequirements(defaults)

    if type(source) == "table" then
        for key in pairs(requirements) do
            local value = tonumber(source[key])

            if value and value >= 0 then
                requirements[key] = math.floor(value + 0.5)
            end
        end
    end

    requirements.tanks = math.max(requirements.tanks, 0)
    requirements.healers = math.max(requirements.healers, 0)
    requirements.melee = math.max(requirements.melee, 0)
    requirements.ranged = math.max(requirements.ranged, 0)
    requirements.dps = math.max(requirements.dps, requirements.melee + requirements.ranged)
    return requirements
end

local function getDefaultDifficulty(activityType)
    if normalizeActivityType(activityType) == "raid" then
        return "Heroic"
    end

    return "Mythic+2"
end

local function normalizeDifficulty(value, activityType)
    local normalized = trimString(value)
    return normalized ~= "" and normalized or getDefaultDifficulty(activityType)
end

local function normalizeDurationHours(value, activityType)
    local defaultHours = normalizeActivityType(activityType) == "raid" and 3 or 2
    return math.max(math.floor(tonumber(value) or defaultHours), 1)
end

local function parseMythicPlusLevel(value)
    local text = string.lower(trimString(value))
    local level = tonumber(text:match("mythic%+(%d+)")) or tonumber(text)

    if not level then
        return nil
    end

    return math.min(math.max(math.floor(level), 2), 20)
end

local function usesMythicPlusTemplate(templateKey, activityType, difficulty)
    if normalizeActivityType(activityType) ~= "party" then
        return false
    end

    local normalizedTemplateKey = trimString(templateKey)

    if normalizedTemplateKey:match("^mythic%-plus") then
        return true
    end

    return string.lower(trimString(difficulty)):match("^mythic%+") ~= nil
end

local function buildDifficulty(activityType, templateKey, difficulty, mythicLevel)
    local normalizedDifficulty = normalizeDifficulty(difficulty, activityType)

    if usesMythicPlusTemplate(templateKey, activityType, normalizedDifficulty) then
        local level = parseMythicPlusLevel(mythicLevel) or parseMythicPlusLevel(normalizedDifficulty) or 2
        return "Mythic+" .. tostring(level)
    end

    return normalizedDifficulty
end

local function buildDefaultEventName(template, mythicLevel)
    if not template then
        return "Activity"
    end

    if usesMythicPlusTemplate(template.key, template.activityType, template.difficulty) then
        local level = parseMythicPlusLevel(mythicLevel) or parseMythicPlusLevel(template.difficulty) or 2
        return "Mythic+" .. tostring(level) .. " Run"
    end

    return template.defaultName or template.label or "Activity"
end

local function getTemplateByKey(templateKey)
    local normalized = trimString(templateKey)

    for _, template in ipairs(ACTIVITY_TEMPLATES) do
        if template.key == normalized then
            return {
                key = template.key,
                label = template.label,
                defaultName = template.defaultName,
                activityType = normalizeActivityType(template.activityType),
                difficulty = normalizeDifficulty(template.difficulty, template.activityType),
                minItemLevel = tonumber(template.minItemLevel) or 0,
                requirements = normalizeRequirements(template.requirements, template.activityType),
            }
        end
    end

    return nil
end

local function parseDateParts(dateText)
    local year, month, day = string.match(trimString(dateText), "^(%d%d%d%d)%-(%d%d)%-(%d%d)$")

    if not year then
        return nil
    end

    return tonumber(year), tonumber(month), tonumber(day)
end

local function parseTimeParts(timeText)
    local hour, minute = string.match(trimString(timeText), "^(%d%d):(%d%d)$")

    if not hour then
        return nil
    end

    return tonumber(hour), tonumber(minute)
end

local function roundToNearestInteger(value)
    local numeric = tonumber(value) or 0

    if numeric >= 0 then
        return math.floor(numeric + 0.5)
    end

    return math.ceil(numeric - 0.5)
end

local function normalizeTimezoneOffsetMinutes(value)
    local numeric = tonumber(value)

    if numeric == nil then
        return nil
    end

    numeric = roundToNearestInteger(numeric)
    return math.max(-840, math.min(840, numeric))
end

local function formatTimezoneOffsetLabel(offsetMinutes)
    local normalized = normalizeTimezoneOffsetMinutes(offsetMinutes) or 0
    local absoluteMinutes = math.abs(normalized)
    local hours = math.floor(absoluteMinutes / 60)
    local minutes = absoluteMinutes % 60

    return string.format("UTC%s%d:%02d", normalized >= 0 and "+" or "-", hours, minutes)
end

local function getTimezoneOffsetMinutesAt(referenceTimestamp)
    local timestamp = tonumber(referenceTimestamp) or time()
    local localParts = date("*t", timestamp)
    local utcParts = date("!*t", timestamp)

    if type(localParts) ~= "table" or type(utcParts) ~= "table" then
        return 0
    end

    local localTimestamp = time({
        year = localParts.year,
        month = localParts.month,
        day = localParts.day,
        hour = localParts.hour,
        min = localParts.min,
        sec = localParts.sec,
        isdst = localParts.isdst,
    })
    local utcAsLocalTimestamp = time({
        year = utcParts.year,
        month = utcParts.month,
        day = utcParts.day,
        hour = utcParts.hour,
        min = utcParts.min,
        sec = utcParts.sec,
        isdst = false,
    })

    if not localTimestamp or not utcAsLocalTimestamp then
        return 0
    end

    return roundToNearestInteger((localTimestamp - utcAsLocalTimestamp) / 60)
end

local function getTimestampPartsForTimezone(timestamp, timezoneOffsetMinutes)
    local resolvedTimestamp = tonumber(timestamp) or 0

    if resolvedTimestamp <= 0 then
        return nil
    end

    local normalizedOffset = normalizeTimezoneOffsetMinutes(timezoneOffsetMinutes)

    if normalizedOffset == nil then
        return date("*t", resolvedTimestamp)
    end

    return date("!*t", resolvedTimestamp + (normalizedOffset * 60))
end

local function buildTimestampForTimezone(year, month, day, hour, minute, second, timezoneOffsetMinutes)
    local localTimestamp = time({
        year = tonumber(year),
        month = tonumber(month),
        day = tonumber(day),
        hour = tonumber(hour) or 0,
        min = tonumber(minute) or 0,
        sec = tonumber(second) or 0,
    })

    if not localTimestamp then
        return nil
    end

    local normalizedOffset = normalizeTimezoneOffsetMinutes(timezoneOffsetMinutes)

    if normalizedOffset == nil then
        return localTimestamp
    end

    local viewerOffset = getTimezoneOffsetMinutesAt(localTimestamp)
    return localTimestamp + ((viewerOffset - normalizedOffset) * 60)
end

local function clampHourMinute(value, maximum)
    local numeric = tonumber(value)

    if numeric == nil then
        return nil
    end

    numeric = math.floor(numeric)
    return math.max(0, math.min(maximum, numeric))
end

local function applyScheduleMetadata(record, startAt, timezoneOffsetMinutes)
    if type(record) ~= "table" then
        return
    end

    local resolvedStartAt = tonumber(startAt) or 0

    if resolvedStartAt <= 0 then
        record.timezoneOffsetMinutes = nil
        record.timezoneLabel = ""
        record.scheduleHour = nil
        record.scheduleMinute = nil
        return
    end

    local resolvedOffset = normalizeTimezoneOffsetMinutes(timezoneOffsetMinutes)

    if resolvedOffset == nil then
        resolvedOffset = getTimezoneOffsetMinutesAt(resolvedStartAt)
    end

    local parts = getTimestampPartsForTimezone(resolvedStartAt, resolvedOffset)
    record.timezoneOffsetMinutes = resolvedOffset
    record.timezoneLabel = formatTimezoneOffsetLabel(resolvedOffset)
    record.scheduleHour = clampHourMinute(parts and parts.hour, 23) or 0
    record.scheduleMinute = clampHourMinute(parts and parts.min, 59) or 0
end

local function buildOccurrenceDisplay(timestamp, timezoneOffsetMinutes)
    local resolvedTimestamp = tonumber(timestamp) or 0
    local normalizedOffset = normalizeTimezoneOffsetMinutes(timezoneOffsetMinutes)

    if resolvedTimestamp <= 0 then
        return {
            creatorCompactLabel = "Set schedule",
            creatorDateText = "",
            creatorLabel = "Set schedule",
            creatorTimeText = "",
            creatorTimezoneLabel = normalizedOffset ~= nil and formatTimezoneOffsetLabel(normalizedOffset) or "",
            localCompactLabel = "Set schedule",
            localDateText = "",
            localLabel = "Set schedule",
            localTimeText = "",
        }
    end

    local localParts = getTimestampPartsForTimezone(resolvedTimestamp, nil)
    local creatorParts = getTimestampPartsForTimezone(resolvedTimestamp, normalizedOffset)
    local creatorTimezoneLabel = normalizedOffset ~= nil and formatTimezoneOffsetLabel(normalizedOffset) or ""
    local creatorDateText = creatorParts and string.format("%04d-%02d-%02d", creatorParts.year, creatorParts.month, creatorParts.day) or ""
    local creatorTimeText = creatorParts and string.format("%02d:%02d", creatorParts.hour or 0, creatorParts.min or 0) or ""
    local localDateText = localParts and string.format("%04d-%02d-%02d", localParts.year, localParts.month, localParts.day) or ""
    local localTimeText = localParts and string.format("%02d:%02d", localParts.hour or 0, localParts.min or 0) or ""
    local localLabel = date("%a %m/%d %H:%M", resolvedTimestamp)
    local localCompactLabel = date("%m/%d %H:%M", resolvedTimestamp)
    local creatorCompactLabel = normalizedOffset ~= nil and string.format("%s %s", creatorDateText:sub(6), creatorTimeText) or localCompactLabel
    local creatorLabel = normalizedOffset ~= nil
        and string.format("%s %s (%s)", creatorDateText, creatorTimeText, creatorTimezoneLabel)
        or localLabel

    return {
        creatorCompactLabel = creatorCompactLabel,
        creatorDateText = creatorDateText,
        creatorLabel = creatorLabel,
        creatorTimeText = creatorTimeText,
        creatorTimezoneLabel = creatorTimezoneLabel,
        localCompactLabel = localCompactLabel,
        localDateText = localDateText,
        localLabel = localLabel,
        localTimeText = localTimeText,
    }
end

local function parseWeekdayList(value)
    if type(value) == "table" then
        local normalized = {}
        local seen = {}

        for _, rawDay in ipairs(value) do
            local day = tonumber(rawDay)

            if day and day >= 1 and day <= 7 and not seen[day] then
                seen[day] = true
                normalized[#normalized + 1] = day
            end
        end

        table.sort(normalized)
        return normalized
    end

    local normalized = {}
    local seen = {}
    local text = string.lower(trimString(value))
    local tokenMap = {
        sun = 1,
        sunday = 1,
        mon = 2,
        monday = 2,
        tue = 3,
        tues = 3,
        tuesday = 3,
        wed = 4,
        weds = 4,
        wednesday = 4,
        thu = 5,
        thur = 5,
        thurs = 5,
        thursday = 5,
        fri = 6,
        friday = 6,
        sat = 7,
        saturday = 7,
    }

    for token in string.gmatch(text, "[^,%s/]+") do
        local day = tokenMap[token] or tonumber(token)

        if day and day >= 1 and day <= 7 and not seen[day] then
            seen[day] = true
            normalized[#normalized + 1] = day
        end
    end

    table.sort(normalized)
    return normalized
end

local function buildWeekdayText(weekdays)
    local labels = {}

    for _, day in ipairs(parseWeekdayList(weekdays)) do
        labels[#labels + 1] = WEEKDAY_LABELS[day] or tostring(day)
    end

    return table.concat(labels, ",")
end

local function normalizeRecurrence(source, startAt, timezoneOffsetMinutes)
    local recurrence = {
        mode = "none",
        interval = 1,
        weekdays = {},
    }

    if type(source) ~= "table" then
        return recurrence
    end

    local mode = string.lower(trimString(source.mode))
    recurrence.mode = (mode == "daily" or mode == "weekly") and mode or "none"
    recurrence.interval = math.max(math.floor(tonumber(source.interval) or 1), 1)
    recurrence.weekdays = parseWeekdayList(source.weekdays)

    if recurrence.mode == "weekly" and #recurrence.weekdays == 0 and tonumber(startAt) and tonumber(startAt) > 0 then
        local parts = getTimestampPartsForTimezone(tonumber(startAt), timezoneOffsetMinutes)
        recurrence.weekdays = { parts and parts.wday or date("*t", tonumber(startAt)).wday }
    end

    return recurrence
end

local function normalizeOccurrenceSignups(source)
    local normalized = {}

    if type(source) ~= "table" then
        return normalized
    end

    for rawKey, entries in pairs(source) do
        local timestamp = tonumber(rawKey)

        if timestamp and timestamp > 0 and type(entries) == "table" then
            normalized[tostring(timestamp)] = cloneMemberList(entries)
        end
    end

    return normalized
end

local function parseStartAt(dateText, timeText, timezoneOffsetMinutes)
    local normalizedDate = trimString(dateText)
    local normalizedTime = trimString(timeText)

    if normalizedDate == "" and normalizedTime == "" then
        return 0
    end

    local year, month, day = parseDateParts(normalizedDate)
    local hour, minute = parseTimeParts(normalizedTime ~= "" and normalizedTime or "00:00")

    if not year or not hour then
        return nil
    end

    if month < 1 or month > 12 or day < 1 or day > 31 or hour < 0 or hour > 23 or minute < 0 or minute > 59 then
        return nil
    end

    local timestamp = buildTimestampForTimezone(year, month, day, hour, minute, 0, timezoneOffsetMinutes)

    if not timestamp then
        return nil
    end

    return timestamp
end

local function getStartSortValue(record)
    local startAt = tonumber(record and record.startAt) or 0
    return startAt > 0 and startAt or 2147483647
end

local function isRecurringRecord(record)
    return record and record.recurrence and record.recurrence.mode and record.recurrence.mode ~= "none"
end

local function buildRecurrenceSummary(record)
    if not isRecurringRecord(record) then
        return "One-time"
    end

    if record.recurrence.mode == "daily" then
        local weekdayText = buildWeekdayText(record.recurrence.weekdays)

        if weekdayText == "Mon,Tue,Wed,Thu,Fri" then
            return "Daily Mon-Fri"
        end

        if weekdayText == "Sun,Mon,Tue,Wed,Thu,Fri,Sat" then
            return "Daily Mon-Sun"
        end

        return weekdayText ~= "" and ("Daily " .. weekdayText) or "Daily"
    end

    local weekdayText = buildWeekdayText(record.recurrence.weekdays)
    return weekdayText ~= "" and ("Weekly " .. weekdayText) or "Weekly"
end

local function isSameCalendarDay(leftTimestamp, rightTimestamp)
    if not leftTimestamp or not rightTimestamp or leftTimestamp <= 0 or rightTimestamp <= 0 then
        return false
    end

    local leftDate = date("*t", leftTimestamp)
    local rightDate = date("*t", rightTimestamp)

    return leftDate.year == rightDate.year and leftDate.yday == rightDate.yday
end

local function formatHoursMinutes(secondsRemaining)
    local totalSeconds = math.max(math.floor(tonumber(secondsRemaining) or 0), 0)
    local hours = math.floor(totalSeconds / 3600)
    local minutes = math.floor((totalSeconds % 3600) / 60)

    return string.format("%d:%02d", hours, minutes)
end

local function isReminderCandidate(startAt)
    local now = time()
    return tonumber(startAt) and tonumber(startAt) > now and isSameCalendarDay(tonumber(startAt), now)
end

local function buildReminderMessage(record, startAt)
    local durationHours = normalizeDurationHours(record and record.durationHours, record and record.activityType)
    local durationLabel = durationHours == 1 and "hour" or "hours"
    local timeUntil = formatHoursMinutes((tonumber(startAt) or 0) - time())

    return string.format(
        "GH3 reminder: %s starts in %s at %s your time and is scheduled for %d %s.",
        record and (record.eventName or record.name) or "Your activity",
        timeUntil,
        date("%H:%M", tonumber(startAt) or 0),
        durationHours,
        durationLabel
    )
end

local function getDayStart(timestamp, timezoneOffsetMinutes)
    local parts = getTimestampPartsForTimezone(timestamp, timezoneOffsetMinutes)

    if type(parts) ~= "table" then
        return 0
    end

    return buildTimestampForTimezone(parts.year, parts.month, parts.day, 0, 0, 0, timezoneOffsetMinutes) or 0
end

local function getCandidateTimestamp(dayStart, templateTimestamp, timezoneOffsetMinutes, scheduleHour, scheduleMinute)
    local day = getTimestampPartsForTimezone(dayStart, timezoneOffsetMinutes)
    local template = getTimestampPartsForTimezone(templateTimestamp, timezoneOffsetMinutes)
    local resolvedHour = clampHourMinute(scheduleHour, 23) or (template and template.hour) or 0
    local resolvedMinute = clampHourMinute(scheduleMinute, 59) or (template and template.min) or 0

    if type(day) ~= "table" then
        return 0
    end

    return buildTimestampForTimezone(day.year, day.month, day.day, resolvedHour, resolvedMinute, 0, timezoneOffsetMinutes) or 0
end

local function getWeekStart(timestamp, timezoneOffsetMinutes)
    local dayStart = getDayStart(timestamp, timezoneOffsetMinutes)
    local parts = getTimestampPartsForTimezone(timestamp, timezoneOffsetMinutes)
    local weekday = parts and parts.wday or date("*t", timestamp).wday
    return dayStart - ((weekday - 1) * 86400)
end

local function getUpcomingOccurrenceStarts(record, limit)
    local startAt = tonumber(record and record.startAt) or 0
    local timezoneOffsetMinutes = record and record.timezoneOffsetMinutes or nil
    local scheduleHour = record and record.scheduleHour or nil
    local scheduleMinute = record and record.scheduleMinute or nil
    local maxItems = math.max(tonumber(limit) or 1, 1)
    local occurrences = {}

    if startAt <= 0 then
        return occurrences
    end

    if not isRecurringRecord(record) then
        occurrences[1] = startAt
        return occurrences
    end

    local now = time()

    if record.recurrence.mode == "daily" then
        local weekdays = parseWeekdayList(record.recurrence.weekdays)

        if #weekdays == 0 then
            local intervalSeconds = math.max(record.recurrence.interval or 1, 1) * 86400
            local nextStart = startAt

            while nextStart < now do
                nextStart = nextStart + intervalSeconds
            end

            for index = 1, maxItems do
                occurrences[index] = nextStart + ((index - 1) * intervalSeconds)
            end

            return occurrences
        end

        local seen = {}
        local scanStart = math.max(getDayStart(startAt, timezoneOffsetMinutes), getDayStart(now, timezoneOffsetMinutes))

        for offset = 0, 60 do
            local dayStart = scanStart + (offset * 86400)
            local dayParts = getTimestampPartsForTimezone(dayStart, timezoneOffsetMinutes)
            local weekday = dayParts and dayParts.wday or date("*t", dayStart).wday

            if containsValue(weekdays, weekday) then
                local candidate = getCandidateTimestamp(dayStart, startAt, timezoneOffsetMinutes, scheduleHour, scheduleMinute)

                if candidate >= startAt and candidate >= now and not seen[candidate] then
                    seen[candidate] = true
                    occurrences[#occurrences + 1] = candidate

                    if #occurrences >= maxItems then
                        break
                    end
                end
            end
        end

        return occurrences
    end

    local weekdays = parseWeekdayList(record.recurrence.weekdays)

    if #weekdays == 0 then
        local parts = getTimestampPartsForTimezone(startAt, timezoneOffsetMinutes)
        weekdays[1] = parts and parts.wday or date("*t", startAt).wday
    end

    local seen = {}
    local anchorWeekStart = getWeekStart(startAt, timezoneOffsetMinutes)
    local scanStart = math.max(getDayStart(startAt, timezoneOffsetMinutes), getDayStart(now, timezoneOffsetMinutes))

    for offset = 0, 180 do
        local dayStart = scanStart + (offset * 86400)
        local dayParts = getTimestampPartsForTimezone(dayStart, timezoneOffsetMinutes)
        local weekday = dayParts and dayParts.wday or date("*t", dayStart).wday

        if containsValue(weekdays, weekday) then
            local weekDistance = math.floor((getWeekStart(dayStart, timezoneOffsetMinutes) - anchorWeekStart) / 604800)

            if weekDistance >= 0 and (weekDistance % math.max(record.recurrence.interval or 1, 1)) == 0 then
                local candidate = getCandidateTimestamp(dayStart, startAt, timezoneOffsetMinutes, scheduleHour, scheduleMinute)

                if candidate >= startAt and candidate >= now and not seen[candidate] then
                    seen[candidate] = true
                    occurrences[#occurrences + 1] = candidate

                    if #occurrences >= maxItems then
                        break
                    end
                end
            end
        end
    end

    return occurrences
end

local function resolveOccurrenceStart(record, occurrenceStartAt)
    if not record then
        return nil
    end

    if not isRecurringRecord(record) then
        return tonumber(record.startAt) or 0
    end

    local requested = tonumber(occurrenceStartAt)

    if requested and requested > 0 then
        return requested
    end

    local occurrences = getUpcomingOccurrenceStarts(record, 1)
    return occurrences[1]
end

local function getDisplayStartSortValue(record)
    local nextOccurrenceStart = resolveOccurrenceStart(record)
    return getStartSortValue({ startAt = nextOccurrenceStart })
end

getSignupList = function(record, occurrenceStartAt, createIfMissing)
    if not isRecurringRecord(record) then
        record.members = record.members or {}
        return record.members, tonumber(record.startAt) or 0
    end

    local resolvedStartAt = resolveOccurrenceStart(record, occurrenceStartAt)

    if not resolvedStartAt or resolvedStartAt <= 0 then
        return nil, nil
    end

    record.occurrenceSignups = record.occurrenceSignups or {}
    local key = tostring(resolvedStartAt)

    if createIfMissing and type(record.occurrenceSignups[key]) ~= "table" then
        record.occurrenceSignups[key] = {}
    end

    return record.occurrenceSignups[key] or {}, resolvedStartAt
end

local function getPossibleRoles(entry)
    local roles = {}
    local seen = {}

    local function addRole(value)
        local normalized = normalizeRoleKey(value)

        if normalized and not seen[normalized] then
            seen[normalized] = true
            roles[#roles + 1] = normalized
        end
    end

    addRole(entry.primaryRole)
    addRole(entry.role)

    for _, role in ipairs(entry.secondaryRoles or {}) do
        addRole(role)
    end

    if #roles == 0 then
        roles[1] = "dps"
    end

    return roles
end

local function supportsRole(entry, role)
    return containsValue(getPossibleRoles(entry), role)
end

local function matchesDamageProfile(entry, expected)
    local profile = normalizeProfileKey(entry.meleeOrRanged)

    if expected == "melee" or expected == "ranged" then
        return profile == expected or profile == "hybrid"
    end

    return true
end

local function sortPlannerEntries(left, right)
    local leftPrimaryRole = normalizeRoleKey(left.primaryRole)
    local rightPrimaryRole = normalizeRoleKey(right.primaryRole)

    if (tonumber(left.itemLevel) or 0) == (tonumber(right.itemLevel) or 0) then
        if tostring(leftPrimaryRole or "") == tostring(rightPrimaryRole or "") then
            return tostring(left.fullName or "") < tostring(right.fullName or "")
        end

        return tostring(leftPrimaryRole or "") < tostring(rightPrimaryRole or "")
    end

    return (tonumber(left.itemLevel) or 0) > (tonumber(right.itemLevel) or 0)
end

local function getRecordTimestamp()
    return time()
end

getGroupTable = function()
    return addon.Store:GetGuild().groups
end

local function getNextGroupOrder(owner)
    local maxOrder = 0

    for _, record in pairs(getGroupTable()) do
        if not record.deleted and record.owner == owner and (record.order or 0) > maxOrder then
            maxOrder = record.order or 0
        end
    end

    return maxOrder + 1
end

local function copyMemberSnapshot(memberOrId)
    local member = type(memberOrId) == "table" and memberOrId or (addon.RosterService:GetMyToonMember(memberOrId) or addon.RosterService:GetMember(memberOrId))
    local memberId = addon.Store:NormalizeFullName(member and (member.id or member.fullName) or memberOrId, GetRealmName() or "Unknown")
    local fullName = addon.Store:NormalizeFullName(member and (member.fullName or member.id) or memberOrId, GetRealmName() or "Unknown")

    return {
        memberId = memberId,
        fullName = fullName,
        classFile = trimString(member and member.classFile),
        className = member and (member.className or member.class or "Unknown") or "Unknown",
        meleeOrRanged = normalizeProfileKey(member and member.meleeOrRanged),
        primaryRole = normalizeRoleKey(member and (member.primaryRole or member.role)),
        role = normalizeRoleLabel(member and (member.primaryRole or member.role)),
        secondaryRoles = cloneList(member and member.secondaryRoles or {}),
        spec = member and member.spec or nil,
        itemLevel = tonumber(member and (member.itemLevel or member.ilvl)) or 0,
        updatedAt = getRecordTimestamp(),
    }
end

normalizeMemberEntry = function(entry)
    if type(entry) == "string" then
        return copyMemberSnapshot(entry)
    end

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

    local memberId = addon.Store:NormalizeFullName(entry.memberId or entry.id or entry.fullName, GetRealmName() or "Unknown")
    local snapshot = copyMemberSnapshot(memberId)

    snapshot.fullName = addon.Store:NormalizeFullName(entry.fullName or snapshot.fullName, GetRealmName() or "Unknown")
    snapshot.classFile = trimString(entry.classFile or snapshot.classFile)
    snapshot.className = entry.className or entry.class or snapshot.className
    snapshot.meleeOrRanged = normalizeProfileKey(entry.meleeOrRanged or snapshot.meleeOrRanged)
    snapshot.primaryRole = normalizeRoleKey(entry.primaryRole or entry.role or snapshot.primaryRole)
    snapshot.role = normalizeRoleLabel(entry.role or entry.primaryRole or snapshot.role)
    snapshot.secondaryRoles = cloneList(entry.secondaryRoles or snapshot.secondaryRoles)
    snapshot.spec = entry.spec or snapshot.spec
    snapshot.itemLevel = tonumber(entry.itemLevel or entry.ilvl) or snapshot.itemLevel
    snapshot.updatedAt = entry.updatedAt or snapshot.updatedAt
    return snapshot
end

normalizeGroupRecord = function(record)
    if not record then
        return nil
    end

    record.members = record.members or {}
    record.order = record.order or getNextGroupOrder(record.owner or addon.Store:GetPlayerName())
    record.activityType = normalizeActivityType(record.activityType)
    record.difficulty = normalizeDifficulty(record.difficulty, record.activityType)
    record.eventName = trimString(record.eventName) ~= "" and trimString(record.eventName) or trimString(record.name)
    record.minItemLevel = tonumber(record.minItemLevel) or 0
    record.durationHours = normalizeDurationHours(record.durationHours, record.activityType)
    record.startAt = tonumber(record.startAt) or 0
    record.timezoneOffsetMinutes = normalizeTimezoneOffsetMinutes(record.timezoneOffsetMinutes)
    record.timezoneLabel = trimString(record.timezoneLabel)
    record.scheduleHour = clampHourMinute(record.scheduleHour, 23)
    record.scheduleMinute = clampHourMinute(record.scheduleMinute, 59)
    record.templateKey = trimString(record.templateKey)

    if record.startAt > 0 then
        if record.timezoneOffsetMinutes ~= nil then
            if record.timezoneLabel == "" then
                record.timezoneLabel = formatTimezoneOffsetLabel(record.timezoneOffsetMinutes)
            end

            if record.scheduleHour == nil or record.scheduleMinute == nil then
                local scheduleParts = getTimestampPartsForTimezone(record.startAt, record.timezoneOffsetMinutes)
                record.scheduleHour = clampHourMinute(scheduleParts and scheduleParts.hour, 23) or 0
                record.scheduleMinute = clampHourMinute(scheduleParts and scheduleParts.min, 59) or 0
            end
        elseif record.scheduleHour == nil or record.scheduleMinute == nil then
            local localScheduleParts = getTimestampPartsForTimezone(record.startAt, nil)
            record.scheduleHour = clampHourMinute(localScheduleParts and localScheduleParts.hour, 23) or 0
            record.scheduleMinute = clampHourMinute(localScheduleParts and localScheduleParts.min, 59) or 0
        end
    end

    record.recurrence = normalizeRecurrence(record.recurrence, record.startAt, record.timezoneOffsetMinutes)
    record.requirements = normalizeRequirements(record.requirements, record.activityType)
    record.occurrenceSignups = normalizeOccurrenceSignups(record.occurrenceSignups)
    record.recurrenceSummary = buildRecurrenceSummary(record)
    record.nextStartAt = resolveOccurrenceStart(record)

    for index, entry in ipairs(record.members) do
        record.members[index] = normalizeMemberEntry(entry)
    end

    return record
end

local function buildOnlineState(service)
    local onlineState = {}
    local currentGuildKey = addon.Store:GetGuildKey()

    if not currentGuildKey then
        return onlineState
    end

    local trackedMemberIds = getReminderTrackedMemberIds(service, currentGuildKey)

    for memberId in pairs(trackedMemberIds) do
        local member = addon.RosterService:GetMember(memberId)

        onlineState[memberId] = type(member) == "table"
            and not member.deleted
            and not member.leftGuild
            and member.guildName == currentGuildKey
            and member.online
            and true
            or false
    end

    return onlineState
end

local function memberIsSignedUpForOccurrence(record, memberId, occurrenceStartAt)
    local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")
    local memberList = select(1, getSignupList(record, occurrenceStartAt, false)) or record.members or {}

    for _, entry in ipairs(memberList) do
        local entryId = addon.Store:NormalizeFullName(entry.memberId or entry.id or entry.fullName, GetRealmName() or "Unknown")

        if entryId == normalizedMemberId then
            return true
        end
    end

    return false
end

local function getOnlineRosterMember(memberRef)
    local memberId = addon.Store:NormalizeFullName(type(memberRef) == "table" and (memberRef.memberId or memberRef.id or memberRef.fullName) or memberRef, GetRealmName() or "Unknown")

    if isPlaceholderMemberId(memberId) then
        return nil
    end

    local rosterMember = addon.RosterService:GetMember(memberId)

    if rosterMember and rosterMember.online then
        return rosterMember
    end

    return nil
end

local function maybeConvertToRaid(record)
    if record and record.activityType == "raid" and not IsInRaid() and GetNumGroupMembers() > 0 then
        ConvertToRaid()
    end
end

getTodayOccurrences = function(record)
    local starts = {}

    if not record then
        return starts
    end

    if isRecurringRecord(record) then
        for _, startAt in ipairs(getUpcomingOccurrenceStarts(record, 8)) do
            if isReminderCandidate(startAt) then
                starts[#starts + 1] = startAt
            end
        end

        return starts
    end

    local resolvedStartAt = resolveOccurrenceStart(record, record.startAt)

    if isReminderCandidate(resolvedStartAt) then
        starts[1] = resolvedStartAt
    end

    return starts
end

local function markGroupsUpdated(record)
    record.updatedAt = getRecordTimestamp()
    record.updatedBy = addon.Store:GetPlayerName()
    record.revision = (record.revision or 0) + 1
    addon.Store:GetSync().tableMeta.groups.updatedAt = record.updatedAt
end

local function findMemberEntryIndex(record, memberRef)
    local expectedId = addon.Store:NormalizeFullName(type(memberRef) == "table" and (memberRef.memberId or memberRef.id or memberRef.fullName) or memberRef, GetRealmName() or "Unknown")

    for index, entry in ipairs(record.members or {}) do
        if entry.memberId == expectedId or entry.fullName == expectedId then
            return index
        end
    end

    return nil
end

local function getUnitIdByFullName(fullName)
    fullName = addon.Store:NormalizeFullName(fullName, GetRealmName() or "Unknown")
    local shortName = tostring(fullName or ""):match("([^%-]+)")

    if UnitName("player") == shortName then
        return "player"
    end

    local groupPrefix = IsInRaid() and "raid" or "party"
    local groupCount = GetNumGroupMembers()

    for index = 1, groupCount do
        local unitId = groupPrefix .. index

        if UnitExists(unitId) then
            local unitName = UnitName(unitId)
            local unitRealm = GetRealmName()
            local unitFullName = addon.Store:NormalizeFullName(unitName and unitRealm and (unitName .. "-" .. unitRealm) or unitName, unitRealm)

            if unitFullName == fullName or unitName == shortName then
                return unitId
            end
        end
    end

    return nil
end

local function calculateUnitItemLevel(unitId)
    local totalItemLevel = 0
    local itemCount = 0

    for slot = 1, 18 do
        if slot ~= 4 then
            local itemLink = GetInventoryItemLink(unitId, slot)

            if itemLink then
                local itemLevel = GetDetailedItemLevelInfo(itemLink)

                if itemLevel then
                    totalItemLevel = totalItemLevel + itemLevel
                    itemCount = itemCount + 1
                end
            end
        end
    end

    if itemCount == 0 then
        return 0
    end

    return math.floor(((totalItemLevel / itemCount) * 10) + 0.5) / 10
end

function addon.GroupsService:ListGroups(filters)
    local filterType = string.lower(trimString(filters and filters.activityType or "all"))
    local sortBy = string.lower(trimString(filters and filters.sortBy or "start"))
    local groups = addon.Repositories:List("groups", function(record)
        if record.deleted then
            return false
        end

        if filterType ~= "all" and normalizeActivityType(record.activityType) ~= filterType then
            return false
        end

        return true
    end)

    for _, record in ipairs(groups) do
        normalizeGroupRecord(record)
    end

    table.sort(groups, function(left, right)
        if sortBy == "type" then
            if (left.activityType or "") == (right.activityType or "") then
                if getDisplayStartSortValue(left) == getDisplayStartSortValue(right) then
                    return (left.eventName or left.name or "") < (right.eventName or right.name or "")
                end

                return getDisplayStartSortValue(left) < getDisplayStartSortValue(right)
            end

            return (left.activityType or "") < (right.activityType or "")
        end

        if getDisplayStartSortValue(left) == getDisplayStartSortValue(right) then
            if (left.activityType or "") == (right.activityType or "") then
                if (left.order or 0) == (right.order or 0) then
                    return (left.eventName or left.name or "") < (right.eventName or right.name or "")
                end

                return (left.order or 0) < (right.order or 0)
            end

            return (left.activityType or "") < (right.activityType or "")
        end

        return getDisplayStartSortValue(left) < getDisplayStartSortValue(right)
    end)

    return groups
end

function addon.GroupsService:GetGroup(groupId)
    return normalizeGroupRecord(addon.Repositories:Get("groups", groupId))
end

function addon.GroupsService:GetActivityTemplates()
    local templates = {}

    for _, template in ipairs(ACTIVITY_TEMPLATES) do
        templates[#templates + 1] = getTemplateByKey(template.key)
    end

    return templates
end

function addon.GroupsService:GetTemplate(templateKey)
    return getTemplateByKey(templateKey)
end

function addon.GroupsService:IsGuildMaster()
    local _, _, rankIndex = GetGuildInfo("player")
    return tonumber(rankIndex) == 0
end

function addon.GroupsService:IsRaidLeader()
    if isInInstanceGroup() and UnitIsGroupLeader("player", LE_PARTY_CATEGORY_INSTANCE) then
        return true
    end

    if UnitIsGroupLeader and UnitIsGroupLeader("player") then
        return true
    end

    if UnitIsGroupAssistant and (IsInRaid() or isInInstanceGroup()) then
        if UnitIsGroupAssistant("player", LE_PARTY_CATEGORY_INSTANCE) or UnitIsGroupAssistant("player") then
            return true
        end
    end

    return false
end

function addon.GroupsService:CanManageGroups()
    return self:IsGuildMaster() or canEditOfficerNote()
end

function addon.GroupsService:Initialize()
    runtime.onlineState = runtime.onlineState or {}
    runtime.pendingReminderKeys = runtime.pendingReminderKeys or {}
    runtime.reminderTrackedMembersCache = runtime.reminderTrackedMembersCache or nil
    runtime.sentReminderKeys = runtime.sentReminderKeys or {}
end

function addon.GroupsService:CanManageGroup(groupOrId)
    local record = type(groupOrId) == "table" and groupOrId or self:GetGroup(groupOrId)

    if not record then
        return false
    end

    return self:CanManageGroups()
end

function addon.GroupsService:GetStartFields(groupOrId)
    local record = type(groupOrId) == "table" and groupOrId or self:GetGroup(groupOrId)
    local startAt = record and tonumber(record.startAt) or 0

    if not startAt or startAt <= 0 then
        return "", ""
    end

    return date("%Y-%m-%d", startAt), date("%H:%M", startAt)
end

function addon.GroupsService:GetCurrentTimezoneInfo(dateText, timeText)
    local parsedStartAt = parseStartAt(dateText, timeText)
    local offsetMinutes = getTimezoneOffsetMinutesAt(parsedStartAt and parsedStartAt > 0 and parsedStartAt or time())

    return {
        label = formatTimezoneOffsetLabel(offsetMinutes),
        offsetMinutes = offsetMinutes,
    }
end

function addon.GroupsService:GetOccurrenceDisplay(groupOrId, occurrenceStartAt)
    local record = type(groupOrId) == "table" and groupOrId or self:GetGroup(groupOrId)
    local resolvedStartAt = type(occurrenceStartAt) == "number" and occurrenceStartAt or resolveOccurrenceStart(record, occurrenceStartAt)

    return buildOccurrenceDisplay(resolvedStartAt, record and record.timezoneOffsetMinutes or nil)
end

function addon.GroupsService:GetUpcomingOccurrences(groupOrId, limit)
    local record = type(groupOrId) == "table" and groupOrId or self:GetGroup(groupOrId)
    local occurrences = {}

    if not record then
        return occurrences
    end

    for _, startAt in ipairs(getUpcomingOccurrenceStarts(record, limit or 5)) do
        local display = buildOccurrenceDisplay(startAt, record.timezoneOffsetMinutes)
        occurrences[#occurrences + 1] = {
            startAt = startAt,
            creatorLabel = display.creatorLabel,
            label = display.localLabel,
            localLabel = display.localLabel,
            timezoneLabel = display.creatorTimezoneLabel,
        }
    end

    return occurrences
end

function addon.GroupsService:GetResolvedOccurrenceStart(groupOrId, occurrenceStartAt)
    local record = type(groupOrId) == "table" and groupOrId or self:GetGroup(groupOrId)
    return resolveOccurrenceStart(record, occurrenceStartAt)
end

function addon.GroupsService:GetRecurrenceFields(groupOrId)
    local record = type(groupOrId) == "table" and groupOrId or self:GetGroup(groupOrId)

    if not record then
        return "none", ""
    end

    return record.recurrence.mode or "none", buildWeekdayText(record.recurrence.weekdays)
end

function addon.GroupsService:CreateGroup(name, options)
    self:Initialize()

    if not self:CanManageGroups() then
        return nil, "You do not have permission to create activities."
    end

    local groupId = addon.Repositories:NextId("group")
    local owner = addon.Store:GetPlayerName()
    local template = options and options.templateKey and self:GetTemplate(options.templateKey) or nil
    local activityType = normalizeActivityType(options and options.activityType or template and template.activityType or "party")
    local eventName = trimString(name)

    if eventName == "" and template then
        eventName = buildDefaultEventName(template, options and options.mythicLevel)
    end

    local parsedStartAt = nil
    local scheduleOffsetMinutes = nil

    if options and (options.startDate ~= nil or options.startTime ~= nil) then
        parsedStartAt = parseStartAt(options.startDate, options.startTime)

        if parsedStartAt == nil then
            return nil, "Start date must use YYYY-MM-DD and time must use HH:MM."
        end

        if parsedStartAt > 0 then
            scheduleOffsetMinutes = getTimezoneOffsetMinutesAt(parsedStartAt)
        end
    end

    local startAt = options and options.startAt or parsedStartAt or 0
    local difficulty = buildDifficulty(activityType, template and template.key or "", options and options.difficulty or template and template.difficulty or getDefaultDifficulty(activityType), options and options.mythicLevel)
    local minItemLevel = math.max(tonumber(options and options.minItemLevel) or tonumber(template and template.minItemLevel) or 0, 0)
    local durationHours = normalizeDurationHours(options and options.durationHours, activityType)
    local requirements = template and cloneRequirements(template.requirements) or normalizeRequirements(nil, activityType)

    if options and options.requirements then
        requirements = normalizeRequirements(options.requirements, activityType)
    end

    requirements.dps = requirements.melee + requirements.ranged

    local recurrence = normalizeRecurrence({
        mode = options and options.recurrenceMode or "none",
        interval = options and options.recurrenceInterval or 1,
        weekdays = options and options.recurrenceWeekdays or {},
    }, startAt, scheduleOffsetMinutes)

    if recurrence.mode ~= "none" and (tonumber(startAt) or 0) <= 0 then
        return nil, "Recurring activities need a start date and time."
    end

    if #recurrence.weekdays == 0 and recurrence.mode ~= "none" then
        if recurrence.mode == "weekly" then
            return nil, "Weekly recurrence needs at least one weekday."
        end

        recurrence.weekdays = { 1, 2, 3, 4, 5, 6, 7 }
    end

    local record = {
        id = groupId,
        name = eventName ~= "" and eventName or "Activity",
        eventName = eventName ~= "" and eventName or "Activity",
        activityType = activityType,
        difficulty = difficulty,
        minItemLevel = minItemLevel,
        durationHours = durationHours,
        owner = owner,
        order = getNextGroupOrder(owner),
        startAt = startAt,
        templateKey = template and template.key or "",
        recurrence = recurrence,
        requirements = requirements,
        members = {},
        occurrenceSignups = {},
        createdAt = time(),
        updatedAt = time(),
        updatedBy = owner,
        revision = 1,
        deleted = false,
    }

    if startAt > 0 then
        applyScheduleMetadata(record, startAt, scheduleOffsetMinutes)
    end

    record.recurrenceSummary = buildRecurrenceSummary(record)
    record.nextStartAt = resolveOccurrenceStart(record)

    addon.Repositories:Upsert("groups", groupId, record)
    addon.Store:GetSync().tableMeta.groups.updatedAt = time()
    return record
end

function addon.GroupsService:MoveGroup(groupId, direction)
    if not self:CanManageGroup(groupId) then
        return false
    end

    local groups = self:ListGroups()
    local currentIndex = nil

    for index, group in ipairs(groups) do
        if group.id == groupId then
            currentIndex = index
            break
        end
    end

    if not currentIndex then
        return false
    end

    local targetIndex = currentIndex + direction

    if targetIndex < 1 or targetIndex > #groups then
        return false
    end

    local current = groups[currentIndex]
    local target = groups[targetIndex]
    local currentOrder = current.order or currentIndex

    current.order = target.order or targetIndex
    target.order = currentOrder
    markGroupsUpdated(current)
    markGroupsUpdated(target)
    return true
end

function addon.GroupsService:AddMember(groupId, memberOrId, occurrenceStartAt)
    local record = self:GetGroup(groupId)

    if not record then
        return false
    end

    local currentPlayerId = addon.Store:GetPlayerName()
    local targetId = type(memberOrId) == "table" and (memberOrId.memberId or memberOrId.id or memberOrId.fullName) or memberOrId

    if targetId ~= currentPlayerId and not self:CanManageGroup(record) then
        return false
    end

    local snapshot = copyMemberSnapshot(memberOrId)
    local memberList = getSignupList(record, occurrenceStartAt, true)

    if not memberList then
        return false
    end

    for _, existing in ipairs(memberList) do
        if existing.memberId == snapshot.memberId or existing.fullName == snapshot.fullName then
            return true
        end
    end

    table.insert(memberList, snapshot)
    markGroupsUpdated(record)
    return true
end

function addon.GroupsService:AddPartyOrRaidMember(groupId, fullName, occurrenceStartAt)
    if not self:CanManageGroup(groupId) then
        return false
    end

    fullName = addon.Store:NormalizeFullName(fullName, GetRealmName() or "Unknown")

    local unitId = getUnitIdByFullName(fullName)
    local className = "Unknown"
    local classFile = nil

    if unitId and UnitClass then
        className, classFile = UnitClass(unitId)
    end

    local role = normalizeRoleLabel(unitId and UnitGroupRolesAssigned(unitId) or nil)
    local entry = {
        memberId = fullName,
        fullName = fullName,
        classFile = trimString(classFile),
        className = className,
        primaryRole = normalizeRoleKey(role),
        role = role,
        itemLevel = 0,
        updatedAt = getRecordTimestamp(),
    }

    return self:AddMember(groupId, entry, occurrenceStartAt)
end

function addon.GroupsService:UpdateGroupConfig(groupId, payload)
    self:Initialize()

    local record = self:GetGroup(groupId)

    if not record then
        return false
    end

    if not self:CanManageGroup(record) then
        return false, "You do not have permission to edit that activity."
    end

    local template = payload and payload.templateKey and self:GetTemplate(payload.templateKey) or nil
    local nextActivityType = template and template.activityType or (payload and payload.activityType and normalizeActivityType(payload.activityType) or record.activityType)
    local changedActivityType = nextActivityType ~= record.activityType
    local previousRecurrence = normalizeRecurrence(record.recurrence, record.startAt, record.timezoneOffsetMinutes)
    local scheduleChanged = false
    local scheduleOffsetMinutes = record.timezoneOffsetMinutes

    if payload and (payload.startDate ~= nil or payload.startTime ~= nil) then
        local nextStartAt = parseStartAt(payload.startDate, payload.startTime)

        if nextStartAt == nil then
            return false, "Start date must use YYYY-MM-DD and time must use HH:MM."
        end

        scheduleChanged = nextStartAt ~= (tonumber(record.startAt) or 0)
        record.startAt = nextStartAt

        if scheduleChanged and nextStartAt > 0 then
            scheduleOffsetMinutes = getTimezoneOffsetMinutesAt(nextStartAt)
        end
    elseif payload and payload.startAt ~= nil then
        local nextStartAt = tonumber(payload.startAt) or 0
        scheduleChanged = nextStartAt ~= (tonumber(record.startAt) or 0)
        record.startAt = nextStartAt

        if scheduleChanged and nextStartAt > 0 then
            scheduleOffsetMinutes = getTimezoneOffsetMinutesAt(nextStartAt)
        end
    end

    if template then
        record.templateKey = template.key
        record.activityType = template.activityType
        record.difficulty = buildDifficulty(template.activityType, template.key, template.difficulty, payload and payload.mythicLevel)
        record.minItemLevel = template.minItemLevel
        record.requirements = cloneRequirements(template.requirements)

        if trimString(payload.eventName or "") == "" then
            record.eventName = buildDefaultEventName(template, payload and payload.mythicLevel)
        end
    end

    record.activityType = nextActivityType
    record.name = trimString(payload and payload.name or record.name)
    record.eventName = trimString(payload and payload.eventName or record.eventName)
    record.eventName = record.eventName ~= "" and record.eventName or record.name
    record.difficulty = buildDifficulty(record.activityType, record.templateKey, payload and payload.difficulty or record.difficulty, payload and payload.mythicLevel)
    record.minItemLevel = math.max(tonumber(payload and payload.minItemLevel) or tonumber(record.minItemLevel) or 0, 0)
    record.durationHours = normalizeDurationHours(payload and payload.durationHours or record.durationHours, record.activityType)

    if changedActivityType and not (payload and payload.requirements) then
        record.requirements = normalizeRequirements(nil, record.activityType)
    else
        record.requirements = normalizeRequirements(payload and payload.requirements or record.requirements, record.activityType)
    end

    record.requirements.dps = record.requirements.melee + record.requirements.ranged

    record.recurrence = normalizeRecurrence({
        mode = payload and payload.recurrenceMode or record.recurrence.mode,
        interval = payload and payload.recurrenceInterval or record.recurrence.interval,
        weekdays = payload and payload.recurrenceWeekdays or record.recurrence.weekdays,
    }, record.startAt, scheduleOffsetMinutes)

    if record.recurrence.mode ~= "none" and (tonumber(record.startAt) or 0) <= 0 then
        return false, "Recurring activities need a start date and time."
    end

    if record.recurrence.mode == "weekly" and #record.recurrence.weekdays == 0 then
        return false, "Weekly recurrence needs at least one weekday."
    end

    if previousRecurrence.mode == "none" and record.recurrence.mode ~= "none" and #(record.members or {}) > 0 then
        local seedKey = tostring(record.startAt)
        record.occurrenceSignups = record.occurrenceSignups or {}
        if type(record.occurrenceSignups[seedKey]) ~= "table" or #record.occurrenceSignups[seedKey] == 0 then
            record.occurrenceSignups[seedKey] = cloneMemberList(record.members)
        end
        record.members = {}
    elseif previousRecurrence.mode ~= "none" and record.recurrence.mode == "none" and #(record.members or {}) == 0 then
        local fallbackStart = resolveOccurrenceStart(record, record.startAt)
        local fallbackMembers = fallbackStart and select(1, getSignupList(record, fallbackStart, false)) or {}
        record.members = cloneMemberList(fallbackMembers)
    end

    if scheduleChanged and (tonumber(record.startAt) or 0) > 0 then
        applyScheduleMetadata(record, record.startAt, scheduleOffsetMinutes)
    end

    markGroupsUpdated(record)
    return true, record
end

function addon.GroupsService:GetMythicPlusLevel(groupOrId)
    local record = type(groupOrId) == "table" and groupOrId or self:GetGroup(groupOrId)

    if not record then
        return 2
    end

    return parseMythicPlusLevel(record.difficulty) or 2
end

function addon.GroupsService:RemoveMember(groupId, memberRef, occurrenceStartAt)
    local record = self:GetGroup(groupId)

    if not record then
        return false
    end

    local currentPlayerId = addon.Store:GetPlayerName()
    local targetId = type(memberRef) == "table" and (memberRef.memberId or memberRef.id or memberRef.fullName) or memberRef

    if targetId ~= currentPlayerId and not self:CanManageGroup(record) then
        return false
    end

    local memberList = getSignupList(record, occurrenceStartAt, false)

    if not memberList then
        return false
    end

    local entryIndex = findMemberEntryIndex({ members = memberList }, memberRef)

    if not entryIndex then
        return false
    end

    table.remove(memberList, entryIndex)
    markGroupsUpdated(record)
    return true
end

function addon.GroupsService:MoveMember(groupId, memberRef, direction)
    local record = self:GetGroup(groupId)

    if not record then
        return false
    end

    local entryIndex = findMemberEntryIndex(record, memberRef)

    if not entryIndex then
        return false
    end

    local targetIndex = entryIndex + direction

    if targetIndex < 1 or targetIndex > #record.members then
        return false
    end

    local temp = record.members[targetIndex]
    record.members[targetIndex] = record.members[entryIndex]
    record.members[entryIndex] = temp
    markGroupsUpdated(record)
    return true
end

function addon.GroupsService:DeleteGroup(groupId)
    local record = self:GetGroup(groupId)

    if not record then
        return false
    end

    if not self:CanManageGroup(record) then
        return false
    end

    record.deleted = true
    record.updatedAt = time()
    record.updatedBy = addon.Store:GetPlayerName()
    record.revision = (record.revision or 0) + 1
    addon.Store:GetSync().tableMeta.groups.updatedAt = record.updatedAt
    return true
end

function addon.GroupsService:GetMemberRecords(groupId)
    local record = self:GetGroup(groupId)
    local members = {}

    if not record then
        return members
    end

    local sourceMembers = select(1, getSignupList(record, nil, false)) or record.members

    for _, entry in ipairs(sourceMembers) do
        members[#members + 1] = normalizeMemberEntry(entry)
    end

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

    return members
end

function addon.GroupsService:GetPlannerData(groupId, occurrenceStartAt)
    local record = self:GetGroup(groupId)

    if not record then
        return nil
    end

    local requirements = normalizeRequirements(record.requirements, record.activityType)
    local minItemLevel = tonumber(record.minItemLevel) or 0
    local signups = {}
    local qualified = {}
    local roleEligible = 0
    local activeOccurrenceStart = resolveOccurrenceStart(record, occurrenceStartAt)
    local signupEntries = select(1, getSignupList(record, activeOccurrenceStart, false)) or record.members or {}

    for _, entry in ipairs(signupEntries) do
        local normalized = normalizeMemberEntry(entry)
        local roles = getPossibleRoles(normalized)
        local hasRoleFit = containsValue(roles, "tank") or containsValue(roles, "healer") or containsValue(roles, "dps")
        local meetsItemLevel = (tonumber(normalized.itemLevel) or 0) >= minItemLevel

        signups[#signups + 1] = normalized

        if hasRoleFit then
            roleEligible = roleEligible + 1
        end

        if hasRoleFit and meetsItemLevel then
            qualified[#qualified + 1] = normalized
        end
    end

    table.sort(signups, sortPlannerEntries)
    table.sort(qualified, sortPlannerEntries)

    local used = {}
    local suggested = {}
    local filled = {
        tanks = 0,
        healers = 0,
        melee = 0,
        ranged = 0,
        dps = 0,
    }

    local function pickCandidate(expectedRole, expectedProfile)
        local bestIndex = nil
        local bestScore = nil

        for index, candidate in ipairs(qualified) do
            local candidateId = candidate.memberId or candidate.fullName or tostring(index)

            if not used[candidateId] and supportsRole(candidate, expectedRole) and matchesDamageProfile(candidate, expectedProfile) then
                local score = (tonumber(candidate.itemLevel) or 0) * 100

                if normalizeRoleKey(candidate.primaryRole) == expectedRole then
                    score = score + 10000
                elseif containsValue(candidate.secondaryRoles or {}, expectedRole) then
                    score = score + 5000
                end

                if expectedProfile and normalizeProfileKey(candidate.meleeOrRanged) == expectedProfile then
                    score = score + 1000
                end

                if not bestScore or score > bestScore then
                    bestScore = score
                    bestIndex = index
                end
            end
        end

        if not bestIndex then
            return nil
        end

        local picked = qualified[bestIndex]
        local pickedId = picked.memberId or picked.fullName or tostring(bestIndex)

        used[pickedId] = true
        suggested[#suggested + 1] = picked

        if expectedRole == "tank" then
            filled.tanks = filled.tanks + 1
        elseif expectedRole == "healer" then
            filled.healers = filled.healers + 1
        else
            filled.dps = filled.dps + 1
            if expectedProfile == "melee" then
                filled.melee = filled.melee + 1
            elseif expectedProfile == "ranged" then
                filled.ranged = filled.ranged + 1
            else
                local profile = normalizeProfileKey(picked.meleeOrRanged)
                if profile == "melee" then
                    filled.melee = filled.melee + 1
                elseif profile == "ranged" then
                    filled.ranged = filled.ranged + 1
                end
            end
        end

        return picked
    end

    for _ = 1, requirements.tanks do
        if not pickCandidate("tank") then
            break
        end
    end

    for _ = 1, requirements.healers do
        if not pickCandidate("healer") then
            break
        end
    end

    for _ = 1, requirements.melee do
        if not pickCandidate("dps", "melee") then
            break
        end
    end

    for _ = 1, requirements.ranged do
        if not pickCandidate("dps", "ranged") then
            break
        end
    end

    while filled.dps < requirements.dps do
        if not pickCandidate("dps") then
            break
        end
    end

    local deficits = {
        tanks = math.max(requirements.tanks - filled.tanks, 0),
        healers = math.max(requirements.healers - filled.healers, 0),
        melee = math.max(requirements.melee - filled.melee, 0),
        ranged = math.max(requirements.ranged - filled.ranged, 0),
        dps = math.max(requirements.dps - filled.dps, 0),
    }

    local suggestionsById = {}

    for _, candidate in ipairs(suggested) do
        suggestionsById[candidate.memberId or candidate.fullName] = true
    end

    local evaluations = {}
    local statusCounts = {
        suggested = 0,
        ready = 0,
        lowItemLevel = 0,
        noRoleFit = 0,
    }

    for _, entry in ipairs(signups) do
        local entryId = entry.memberId or entry.fullName
        local hasRoleFit = supportsRole(entry, "tank") or supportsRole(entry, "healer") or supportsRole(entry, "dps")
        local meetsItemLevel = (tonumber(entry.itemLevel) or 0) >= minItemLevel
        local status = "No Role Fit"
        local reason = "No usable tank, healer, or DPS role found"

        if suggestionsById[entryId] then
            status = "Suggested"
            reason = "Selected for the current best-fit lineup"
            statusCounts.suggested = statusCounts.suggested + 1
        elseif hasRoleFit and meetsItemLevel then
            status = "Ready"
            reason = "Meets role and item level requirements"
            statusCounts.ready = statusCounts.ready + 1
        elseif hasRoleFit then
            status = "Low iLvl"
            reason = string.format("Needs %d more item levels", math.max(minItemLevel - (tonumber(entry.itemLevel) or 0), 0))
            statusCounts.lowItemLevel = statusCounts.lowItemLevel + 1
        else
            statusCounts.noRoleFit = statusCounts.noRoleFit + 1
        end

        evaluations[#evaluations + 1] = {
            entry = entry,
            status = status,
            reason = reason,
        }
    end

    return {
        id = record.id,
        owner = record.owner,
        eventName = record.eventName,
        activityType = record.activityType,
        difficulty = record.difficulty,
        minItemLevel = minItemLevel,
        durationHours = normalizeDurationHours(record.durationHours, record.activityType),
        startAt = tonumber(record.startAt) or 0,
        activeOccurrenceStart = activeOccurrenceStart,
        isRecurring = isRecurringRecord(record),
        recurrenceSummary = buildRecurrenceSummary(record),
        upcomingOccurrences = self:GetUpcomingOccurrences(record, 5),
        templateKey = record.templateKey,
        requirements = requirements,
        signups = signups,
        evaluations = evaluations,
        suggested = suggested,
        deficits = deficits,
        qualifiedCount = #qualified,
        roleEligibleCount = roleEligible,
        totalSignups = #signups,
        statusCounts = statusCounts,
    }
end

function addon.GroupsService:ScheduleSameDayRemindersForMember(memberId)
    self:Initialize()

    if not self:CanManageGroups() then
        return
    end

    local normalizedMemberId = addon.Store:NormalizeFullName(memberId, GetRealmName() or "Unknown")

    if normalizedMemberId == "" or normalizedMemberId == addon.Store:GetPlayerName() then
        return
    end

    local rosterMember = addon.RosterService:GetMember(normalizedMemberId)

    if not rosterMember or not rosterMember.online then
        return
    end

    for _, record in pairs(getGroupTable()) do
        local groupRecord = normalizeGroupRecord(record)

        if groupRecord and not groupRecord.deleted and self:CanManageGroup(groupRecord) then
            for _, occurrenceStartAt in ipairs(getTodayOccurrences(groupRecord)) do
                if memberIsSignedUpForOccurrence(groupRecord, normalizedMemberId, occurrenceStartAt) then
                    local reminderKey = table.concat({ tostring(groupRecord.id), tostring(occurrenceStartAt), normalizedMemberId }, "|")

                    if not runtime.sentReminderKeys[reminderKey] and not runtime.pendingReminderKeys[reminderKey] then
                        runtime.pendingReminderKeys[reminderKey] = true

                        if C_Timer and C_Timer.After then
                            C_Timer.After(GROUP_REMINDER_DELAY_SECONDS, function()
                                runtime.pendingReminderKeys[reminderKey] = nil

                                local refreshedRecord = addon.GroupsService:GetGroup(groupRecord.id)
                                local refreshedMember = addon.RosterService:GetMember(normalizedMemberId)

                                if not refreshedRecord or refreshedRecord.deleted or not refreshedMember or not refreshedMember.online then
                                    return
                                end

                                if not addon.GroupsService:CanManageGroup(refreshedRecord) then
                                    return
                                end

                                if not isReminderCandidate(occurrenceStartAt) or not memberIsSignedUpForOccurrence(refreshedRecord, normalizedMemberId, occurrenceStartAt) then
                                    return
                                end

                                if sendReminderWhisper(buildReminderMessage(refreshedRecord, occurrenceStartAt), refreshedMember) then
                                    runtime.sentReminderKeys[reminderKey] = time()
                                end
                            end)
                        else
                            runtime.pendingReminderKeys[reminderKey] = nil
                        end
                    end
                end
            end
        end
    end
end

function addon.GroupsService:ScheduleSameDayRemindersForOnlineMembers()
    self:Initialize()

    if not self:CanManageGroups() then
        return
    end

    for memberId, isOnline in pairs(buildOnlineState(self)) do
        if isOnline then
            self:ScheduleSameDayRemindersForMember(memberId)
        end
    end
end

function addon.GroupsService:HandlePlayerLogin()
    self:Initialize()

    if not self:CanManageGroups() then
        runtime.onlineState = {}
        return
    end

    runtime.onlineState = buildOnlineState(self)
    self:ScheduleSameDayRemindersForOnlineMembers()
end

function addon.GroupsService:HandleGuildRosterUpdate()
    local perfScopeName = "groups:handle-guild-roster-update"
    local perfStartedAt = addon.BeginPerfScope and addon:BeginPerfScope(perfScopeName) or nil
    local function finishPerf()
        if perfStartedAt ~= nil and addon.EndPerfScope then
            addon:EndPerfScope(perfScopeName, perfStartedAt)
        end
    end

    self:Initialize()

    if not self:CanManageGroups() then
        runtime.onlineState = {}
        finishPerf()
        return
    end

    local previousState = runtime.onlineState or {}
    local currentState = buildOnlineState(self)

    runtime.onlineState = currentState

    for memberId, isOnline in pairs(currentState) do
        if isOnline and not previousState[memberId] then
            self:ScheduleSameDayRemindersForMember(memberId)
        end
    end

    finishPerf()
end

function addon.GroupsService:InviteSuggestedGroup(groupId, occurrenceStartAt)
    local planner = self:GetPlannerData(groupId, occurrenceStartAt)
    local record = self:GetGroup(groupId)
    local invited = 0

    if not planner or not record then
        return false
    end

    if not self:CanManageGroup(groupId) then
        return false
    end

    maybeConvertToRaid(record)

    for _, entry in ipairs(planner.suggested or {}) do
        if getOnlineRosterMember(entry.memberId or entry.fullName) then
            inviteUnit(entry.fullName or entry.memberId)
            invited = invited + 1
        end
    end

    return invited > 0
end

function addon.GroupsService:InviteGroup(groupId, occurrenceStartAt)
    local record = self:GetGroup(groupId)
    local invited = 0

    if not record then
        return false
    end

    if not self:CanManageGroup(groupId) then
        return false
    end

    local memberList = select(1, getSignupList(record, occurrenceStartAt, false)) or record.members or {}

    maybeConvertToRaid(record)

    for _, entry in ipairs(memberList) do
        if getOnlineRosterMember(entry.memberId or entry.fullName) then
            inviteUnit(entry.fullName or entry.memberId)
            invited = invited + 1
        end
    end

    return invited > 0
end

function addon.GroupsService:InviteMember(groupId, memberRef, occurrenceStartAt)
    local record = self:GetGroup(groupId)

    if not record then
        return false
    end

    if not self:CanManageGroup(record) then
        return false
    end

    local memberList = select(1, getSignupList(record, occurrenceStartAt, false)) or record.members or {}
    local entryIndex = findMemberEntryIndex({ members = memberList }, memberRef)

    if not entryIndex then
        return false
    end

    local entry = memberList[entryIndex]
    local rosterMember = getOnlineRosterMember(entry.fullName or entry.memberId)

    if not rosterMember then
        return false
    end

    maybeConvertToRaid(record)
    inviteUnit(entry.fullName or entry.memberId)
    return true
end

function addon.GroupsService:InviteMemberBattleNet(groupId, memberRef, occurrenceStartAt)
    local record = self:GetGroup(groupId)

    if not record or not self:CanManageGroup(record) then
        return false
    end

    local memberList = select(1, getSignupList(record, occurrenceStartAt, false)) or record.members or {}
    local entryIndex = findMemberEntryIndex({ members = memberList }, memberRef)

    if not entryIndex then
        return false
    end

    local entry = memberList[entryIndex]
    local rosterMember = getOnlineRosterMember(entry.fullName or entry.memberId)

    if not rosterMember or not rosterMember.battleTag then
        return false
    end

    return inviteBattleNetFriend(rosterMember.battleTag)
end

function addon.GroupsService:InviteSuggestedOnline(groupId, occurrenceStartAt)
    local planner = self:GetPlannerData(groupId, occurrenceStartAt)
    local record = self:GetGroup(groupId)
    local invited = 0

    if not planner or not record or not self:CanManageGroup(record) then
        return false, 0
    end

    maybeConvertToRaid(record)

    for _, entry in ipairs(planner.suggested or {}) do
        if getOnlineRosterMember(entry.memberId or entry.fullName) then
            inviteUnit(entry.fullName or entry.memberId)
            invited = invited + 1
        end
    end

    return invited > 0, invited
end

function addon.GroupsService:SendSuggestedReminder(groupId, occurrenceStartAt)
    local planner = self:GetPlannerData(groupId, occurrenceStartAt)
    local record = self:GetGroup(groupId)
    local sent = 0

    if not planner or not record or not self:CanManageGroup(record) then
        return false, 0
    end

    for _, entry in ipairs(planner.suggested or {}) do
        local rosterMember = getOnlineRosterMember(entry.memberId or entry.fullName)

        if rosterMember and sendReminderWhisper(buildReminderMessage(record, occurrenceStartAt), rosterMember) then
            sent = sent + 1
        end
    end

    return sent > 0, sent
end

function addon.GroupsService:RefreshMemberFromParty(groupId, memberRef, callback, occurrenceStartAt)
    local record = self:GetGroup(groupId)

    if not record then
        if callback then
            callback(false)
        end
        return false
    end

    if not self:CanManageGroup(record) then
        if callback then
            callback(false)
        end
        return false
    end

    local memberList = select(1, getSignupList(record, occurrenceStartAt, false)) or record.members or {}
    local entryIndex = findMemberEntryIndex({ members = memberList }, memberRef)

    if not entryIndex then
        if callback then
            callback(false)
        end
        return false
    end

    local entry = memberList[entryIndex]
    local unitId = getUnitIdByFullName(entry.fullName)

    if not unitId then
        if callback then
            callback(false)
        end
        return false
    end

    local role = normalizeRoleLabel(UnitGroupRolesAssigned(unitId))
    local unitGuid = UnitGUID(unitId)

    if unitId == "player" then
        local averageItemLevel, equippedItemLevel = GetAverageItemLevel()

        entry.role = role
        entry.itemLevel = tonumber(equippedItemLevel) or tonumber(averageItemLevel) or 0
        entry.updatedAt = getRecordTimestamp()
        markGroupsUpdated(record)
        if callback then
            callback(true, entry)
        end
        return true
    end

    local frame = CreateFrame("Frame")
    frame:RegisterEvent("INSPECT_READY")
    frame:SetScript("OnEvent", function(self, event, guid)
        if event == "INSPECT_READY" and guid == unitGuid then
            entry.role = role
            entry.itemLevel = calculateUnitItemLevel(unitId)
            entry.updatedAt = getRecordTimestamp()
            ClearInspectPlayer()
            self:UnregisterEvent("INSPECT_READY")
            markGroupsUpdated(record)
            if callback then
                callback(true, entry)
            end
        end
    end)

    NotifyInspect(unitId)
    return true
end

function addon.GroupsService:IsSignedUp(groupId, memberId, occurrenceStartAt)
    local record = self:GetGroup(groupId)
    local expectedId = memberId or addon.Store:GetPlayerName()

    if not record then
        return false
    end

    local memberList = select(1, getSignupList(record, occurrenceStartAt, false)) or record.members or {}
    return findMemberEntryIndex({ members = memberList }, expectedId) ~= nil
end

function addon.GroupsService:SignupPlayer(groupId, occurrenceStartAt)
    return self:AddMember(groupId, addon.Store:GetPlayerName(), occurrenceStartAt)
end

function addon.GroupsService:WithdrawPlayer(groupId, occurrenceStartAt)
    return self:RemoveMember(groupId, addon.Store:GetPlayerName(), occurrenceStartAt)
end
