GuildHelper = GuildHelper or {}

local addon = GuildHelper

addon.ToonCelebrationService = addon.ToonCelebrationService or {}

local MAX_MESSAGES = 50
local DEFAULT_LOOKBACK_DAYS = 7
local DEFAULT_LOOKAHEAD_DAYS = 14
local SAMPLE_PREVIEW_NAME = "Guildmate"
local MESSAGE_TIMINGS = { "upcoming", "today", "recent" }

local BIRTHDAY_SETUPS = {
    "Birthday aggro has locked onto you today.",
    "Today marks your official level-up in real life.",
    "Your birthday buff is active and the guild has noticed.",
    "The calendar says it is your day, so the guild is required to be dramatic.",
    "You have officially survived another year of patch notes and questionable pulls.",
    "A birthday has appeared in guild chat and it belongs to you.",
    "The guild bakery has entered combat because it is your birthday.",
    "Today you gain one bonus point in wisdom and at least three in chaos.",
    "The candles are up, the raid markers are down, and it is your birthday.",
    "Real-life experience just dinged and the guild heard the sound effect.",
}

local BIRTHDAY_PUNCHLINES = {
    "May your loot be shiny and your cake be soulbound.",
    "Please accept this legally binding supply of bad puns.",
    "Your candles now scale with raid size.",
    "Today all accidental pulls are officially celebration pulls.",
    "The repair bill is not a gift receipt, but the thought counts.",
}

local ANNIVERSARY_MONTH_SETUPS = {
    "One whole month in the guild and you still have not been returned for store credit.",
    "Thirty-ish days later and your trial period somehow became permanent.",
    "You have completed your first month with the guild without being fed to the murlocs.",
    "One month down and the guild HR owl is cautiously optimistic.",
    "A full month has passed and your membership card still has not been revoked.",
    "You made it through your first guild month with your dignity mostly intact.",
    "A month in guild means you now know where the weird jokes are buried.",
    "One month later and the guild still waves when you log in.",
    "You have officially cleared the one-month boss encounter called Us.",
    "A month in the guild means you are now part of the furniture, but in a heroic way.",
}

local ANNIVERSARY_MONTH_PUNCHLINES = {
    "That is either loyalty or excellent confusion, and we support both.",
    "The snack table respects your commitment.",
    "You now qualify for advanced guild nonsense.",
    "Please continue pretending our calendar is organized.",
    "At this point even the repair vendor knows your name.",
}

local ANNIVERSARY_SIX_MONTH_SETUPS = {
    "Six months in the guild means you have officially endured enough wipes to become folklore.",
    "Half a year with the guild and you still queue up for this circus.",
    "You have reached the six-month milestone, which is basically a veteran badge with glitter.",
    "Six months later and the guild still trusts you near important buttons.",
    "A half-year in the guild means you now understand at least 40 percent of our inside jokes.",
    "You have survived six months of guild life and only minimal emotional splash damage.",
    "Six months with the guild has unlocked the deluxe edition of belonging.",
    "Half a year in and you are no longer a guest, you are one of the reasons the chat scrolls too fast.",
    "You hit the six-month guild mark and the confetti goblin has been notified.",
    "Six months later, the guild tabard still looks good on you and the chaos still fits.",
}

local ANNIVERSARY_SIX_MONTH_PUNCHLINES = {
    "That is a lot of loyalty for a group this powered by snacks and sarcasm.",
    "The guild salute is just jazz hands, but it is heartfelt.",
    "Your parking spot near the summon stone is now semi-official.",
    "Please enjoy this ceremonial pile of imaginary cupcakes.",
    "At six months you are allowed to blame lag with confidence.",
}

local ANNIVERSARY_YEAR_SETUPS = {
    "Year {YEARS} with the guild has been cleared on heroic difficulty.",
    "You just hit {YEARS} year(s) with the guild, which frankly deserves a parade and maybe a nap.",
    "Another guild year is in the books and somehow you are still here by choice.",
    "The guild records confirm {YEARS} year(s) of you making this place better and funnier.",
    "You have reached the {YEARS}-year guild milestone, which is basically raid-ready loyalty.",
    "A full {YEARS} year(s) with the guild means you now count as institutional knowledge.",
    "The guild calendar has stamped {YEARS} year(s) of your chaos as officially legendary.",
    "You have logged {YEARS} year(s) with the guild and the walls still speak highly of you.",
    "Another annual guild milestone has arrived and your tenure now has expansion-level content.",
    "The guild would like to report that {YEARS} year(s) of your presence has dramatically improved the vibe.",
}

local ANNIVERSARY_YEAR_PUNCHLINES = {
    "That is enough time to memorize every boss mechanic and still ignore half of them.",
    "Your loyalty is now old enough to need its own mount collection.",
    "Please accept this honorary crown made entirely of inside jokes.",
    "At this rate we will have to give your chair a plaque.",
    "The guild accountant says your value is now measured in legendary banter.",
}

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

local function sanitizeMessageText(text)
    local sanitized = trimString(text)

    if sanitized == "" then
        return nil
    end

    return sanitized:sub(1, 240)
end

local function clampNumber(value, fallback)
    local resolved = tonumber(value)

    if not resolved then
        return fallback
    end

    return math.max(0, math.floor(resolved))
end

local function copyList(source)
    local copy = {}

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

    return copy
end

local function buildMessageGrid(setups, punchlines)
    local messages = {}

    for _, setup in ipairs(setups or {}) do
        for _, punchline in ipairs(punchlines or {}) do
            messages[#messages + 1] = string.format("%s %s", trimString(setup), trimString(punchline))

            if #messages >= MAX_MESSAGES then
                return messages
            end
        end
    end

    return messages
end

local DEFAULT_MESSAGE_SETS = {
    birthday = buildMessageGrid(BIRTHDAY_SETUPS, BIRTHDAY_PUNCHLINES),
    ["anniversary-month"] = buildMessageGrid(ANNIVERSARY_MONTH_SETUPS, ANNIVERSARY_MONTH_PUNCHLINES),
    ["anniversary-six-month"] = buildMessageGrid(ANNIVERSARY_SIX_MONTH_SETUPS, ANNIVERSARY_SIX_MONTH_PUNCHLINES),
    ["anniversary-year"] = buildMessageGrid(ANNIVERSARY_YEAR_SETUPS, ANNIVERSARY_YEAR_PUNCHLINES),
}

local TIMING_SETUPS = {
    upcoming = {
        "The guild calendar says %s is coming up fast.",
        "Heads up, %s is almost here and the confetti goblin is awake.",
        "The guild alarm is ringing because %s is right around the corner.",
        "This is your official warning that %s is about to happen.",
        "The schedule confirms %s is inbound and the guild is preparing the dramatics.",
        "We have early notice that %s is nearly here.",
        "The guild calendar has highlighted %s in suspiciously bright ink.",
        "Advance hype has been approved because %s is coming up.",
        "The guild scouts report that %s is almost at our doorstep.",
        "Preparations are underway because %s is closing in.",
    },
    recent = {
        "%s just slipped by, but the guild celebration permit is still valid.",
        "We noticed a little late that %s happened and we are sending noise anyway.",
        "The guild calendar informs us that %s just happened, so this is a stylish recovery.",
        "Belated alert: %s already landed and the confetti is still usable.",
        "We are a touch late, but %s still deserves guild chat chaos.",
        "Apparently %s happened moments ago and the guild refuses to act calm about it.",
        "The guild is fashionably late to %s, but still very committed.",
        "Our timing is a little scuffed, but %s absolutely still counts.",
        "This is the official delayed reaction to %s.",
        "We just caught up with the fact that %s happened, and that sounds like a celebration problem.",
    },
}

local function normalizeKind(kind)
    local normalized = string.lower(trimString(kind))

    if normalized == "birthday" then
        return "birthday"
    end

    if normalized == "anniversary-month" or normalized == "month" or normalized == "join-month" then
        return "anniversary-month"
    end

    if normalized == "anniversary-six-month" or normalized == "six-month" or normalized == "join-six-month" then
        return "anniversary-six-month"
    end

    if normalized == "anniversary-year" or normalized == "year" or normalized == "join-year" or normalized == "yearly" then
        return "anniversary-year"
    end

    return "birthday"
end

local function normalizeTiming(timing)
    local normalized = string.lower(trimString(timing))

    if normalized == "upcoming" or normalized == "early" or normalized == "soon" then
        return "upcoming"
    end

    if normalized == "recent" or normalized == "missed" or normalized == "belated" or normalized == "just missed it" then
        return "recent"
    end

    return "today"
end

local function getLegacyMessageField(kind)
    local normalized = normalizeKind(kind)

    if normalized == "anniversary-month" then
        return "anniversaryMonthMessages"
    end

    if normalized == "anniversary-six-month" then
        return "anniversarySixMonthMessages"
    end

    if normalized == "anniversary-year" then
        return "anniversaryYearMessages"
    end

    return "birthdayMessages"
end

local function getMessageField(kind, timing)
    local normalizedKind = normalizeKind(kind)
    local normalizedTiming = normalizeTiming(timing)
    local suffix = normalizedTiming == "upcoming" and "UpcomingMessages" or (normalizedTiming == "recent" and "RecentMessages" or "TodayMessages")

    if normalizedKind == "anniversary-month" then
        return "anniversaryMonth" .. suffix
    end

    if normalizedKind == "anniversary-six-month" then
        return "anniversarySixMonth" .. suffix
    end

    if normalizedKind == "anniversary-year" then
        return "anniversaryYear" .. suffix
    end

    return "birthday" .. suffix
end

local function getPunchlinesForKind(kind)
    local normalized = normalizeKind(kind)

    if normalized == "anniversary-month" then
        return ANNIVERSARY_MONTH_PUNCHLINES
    end

    if normalized == "anniversary-six-month" then
        return ANNIVERSARY_SIX_MONTH_PUNCHLINES
    end

    if normalized == "anniversary-year" then
        return ANNIVERSARY_YEAR_PUNCHLINES
    end

    return BIRTHDAY_PUNCHLINES
end

local function getTimingDescriptor(kind)
    local normalized = normalizeKind(kind)

    if normalized == "anniversary-month" then
        return "your one-month guild anniversary"
    end

    if normalized == "anniversary-six-month" then
        return "your six-month guild anniversary"
    end

    if normalized == "anniversary-year" then
        return "your {YEARS}-year guild anniversary"
    end

    return "your birthday"
end

local function getTimingForOffset(offsetDays)
    local offset = tonumber(offsetDays) or 0

    if offset < 0 then
        return "recent"
    end

    if offset > 0 then
        return "upcoming"
    end

    return "today"
end

local function buildTimedMessageGrid(kind, timing)
    local setups = TIMING_SETUPS[normalizeTiming(timing)] or TIMING_SETUPS.upcoming
    local descriptor = getTimingDescriptor(kind)
    local punchlines = getPunchlinesForKind(kind)
    local messages = {}

    for _, setup in ipairs(setups) do
        for _, punchline in ipairs(punchlines or {}) do
            messages[#messages + 1] = string.format("%s %s", string.format(setup, descriptor), trimString(punchline))

            if #messages >= MAX_MESSAGES then
                return messages
            end
        end
    end

    return messages
end

local function getStatField(kind)
    local normalized = normalizeKind(kind)

    if normalized == "anniversary-month" then
        return "anniversaryMonthSent"
    end

    if normalized == "anniversary-six-month" then
        return "anniversarySixMonthSent"
    end

    if normalized == "anniversary-year" then
        return "anniversaryYearSent"
    end

    return "birthdaySent"
end

local function parseIsoDate(value)
    local yearText, monthText, dayText = trimString(value):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
    return tonumber(yearText), tonumber(monthText), tonumber(dayText)
end

local function sendGuild(text)
    if (addon.IsSuspended and addon:IsSuspended()) or not text or text == "" or not IsInGuild or not IsInGuild() then
        return false
    end

    SendChatMessage(text, "GUILD")
    return true
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 baseName(fullName)
    if not fullName then
        return nil
    end

    return fullName:match("^[^-]+") or fullName
end

local function formatDayKey(year, month, day)
    return string.format("%04d-%02d-%02d", tonumber(year) or 0, tonumber(month) or 0, tonumber(day) or 0)
end

local function getTodayParts()
    local today = date("*t")
    return today.year, today.month, today.day, formatDayKey(today.year, today.month, today.day)
end

local function isBirthdayOnDate(value, currentMonth, currentDay)
    local _, month, day = parseIsoDate(value)
    return month ~= nil and day ~= nil and month == currentMonth and day == currentDay
end

local function getAnniversaryForDate(value, currentYear, currentMonth, currentDay)
    local joinYear, joinMonth, joinDay = parseIsoDate(value)

    if not joinYear or not joinMonth or not joinDay or joinMonth ~= currentMonth or joinDay ~= currentDay then
        return nil
    end

    local monthsSinceJoin = ((currentYear - joinYear) * 12) + (currentMonth - joinMonth)

    if monthsSinceJoin >= 12 and monthsSinceJoin % 12 == 0 then
        return {
            kind = "anniversary-year",
            years = math.floor(monthsSinceJoin / 12),
        }
    end

    if monthsSinceJoin == 6 then
        return {
            kind = "anniversary-six-month",
            years = 0,
        }
    end

    if monthsSinceJoin == 1 then
        return {
            kind = "anniversary-month",
            years = 0,
        }
    end

    return nil
end

local function buildHistoryKey(kind, guildKey, memberId)
    return string.format("%s|%s|%s", normalizeKind(kind), trimString(guildKey), trimString(memberId))
end

function addon.ToonCelebrationService:GetState()
    return addon.Store:GetProfile().toonCelebrations
end

function addon.ToonCelebrationService:GetRuntime()
    self.runtime = self.runtime or {
        reminderKeys = {},
        pendingTimers = {},
    }

    return self.runtime
end

function addon.ToonCelebrationService:GetDefaultSettings()
    return {
        enableAnniversary = true,
        enableBirthday = true,
        lookaheadDays = DEFAULT_LOOKAHEAD_DAYS,
        lookbackDays = DEFAULT_LOOKBACK_DAYS,
    }
end

function addon.ToonCelebrationService:GetDefaultMessages(kind, timing)
    local normalizedTiming = normalizeTiming(timing)

    if normalizedTiming == "today" then
        return copyList(DEFAULT_MESSAGE_SETS[normalizeKind(kind)] or DEFAULT_MESSAGE_SETS.birthday)
    end

    return buildTimedMessageGrid(kind, normalizedTiming)
end

function addon.ToonCelebrationService:EnsureDefaults()
    local state = self:GetState()
    local defaults = self:GetDefaultSettings()

    state.settings = state.settings or {}

    for key, value in pairs(defaults) do
        if state.settings[key] == nil then
            state.settings[key] = value
        end
    end

    state.history = state.history or {}
    state.ignoredHistory = state.ignoredHistory or {}
    state.stats = state.stats or {
        anniversaryMonthSent = 0,
        anniversarySixMonthSent = 0,
        anniversaryYearSent = 0,
        birthdaySent = 0,
        lastSentAt = 0,
    }

    for _, kind in ipairs({ "birthday", "anniversary-month", "anniversary-six-month", "anniversary-year" }) do
        for _, timing in ipairs(MESSAGE_TIMINGS) do
            local fieldName = getMessageField(kind, timing)

            if type(state[fieldName]) ~= "table" or #state[fieldName] == 0 then
                if timing == "today" then
                    local legacyField = getLegacyMessageField(kind)

                    if type(state[legacyField]) == "table" and #state[legacyField] > 0 then
                        state[fieldName] = copyList(state[legacyField])
                    else
                        state[fieldName] = self:GetDefaultMessages(kind, timing)
                    end
                else
                    state[fieldName] = self:GetDefaultMessages(kind, timing)
                end
            end
        end
    end
end

function addon.ToonCelebrationService:Initialize()
    self:EnsureDefaults()
    self:GetRuntime()
end

function addon.ToonCelebrationService:CanAccess()
    if not IsInGuild or not IsInGuild() then
        return false
    end

    if addon.GratsUService and addon.GratsUService.CanAccess then
        return addon.GratsUService:CanAccess()
    end

    local _, _, rankIndex = GetGuildInfo("player")
    return tonumber(rankIndex) == 0 or canEditOfficerNote()
end

function addon.ToonCelebrationService:Pause()
end

function addon.ToonCelebrationService:Resume(options)
    local resolvedOptions = type(options) == "table" and options or {}

    if resolvedOptions.lightResume then
        return
    end

    self:HandlePlayerLogin()
end

function addon.ToonCelebrationService:GetSettings()
    return self:GetState().settings
end

function addon.ToonCelebrationService:GetMessages(kind, timing)
    return self:GetState()[getMessageField(kind, timing)]
end

function addon.ToonCelebrationService:UpdateSettings(payload)
    local settings = self:GetSettings()
    local defaults = self:GetDefaultSettings()
    payload = payload or {}

    if payload.enableBirthday ~= nil then
        settings.enableBirthday = payload.enableBirthday and true or false
    end

    if payload.enableAnniversary ~= nil then
        settings.enableAnniversary = payload.enableAnniversary and true or false
    end

    if payload.lookbackDays ~= nil then
        settings.lookbackDays = clampNumber(payload.lookbackDays, defaults.lookbackDays)
    end

    if payload.lookaheadDays ~= nil then
        settings.lookaheadDays = clampNumber(payload.lookaheadDays, defaults.lookaheadDays)
    end

    return settings
end

function addon.ToonCelebrationService:ResetSettings()
    local state = self:GetState()
    state.settings = self:GetDefaultSettings()
    return state.settings
end

local function resolveAddMessageArgs(timingOrText, text)
    if text == nil then
        return "today", timingOrText
    end

    return timingOrText, text
end

local function resolveUpdateMessageArgs(timingOrIndex, indexOrText, maybeText)
    if maybeText == nil then
        return "today", tonumber(timingOrIndex), indexOrText
    end

    return timingOrIndex, tonumber(indexOrText), maybeText
end

local function resolveDeleteMessageArgs(timingOrIndex, maybeIndex)
    if maybeIndex == nil then
        return "today", tonumber(timingOrIndex)
    end

    return timingOrIndex, tonumber(maybeIndex)
end

function addon.ToonCelebrationService:AddMessage(kind, timing, text)
    local resolvedTiming, resolvedText = resolveAddMessageArgs(timing, text)
    local list = self:GetMessages(kind, resolvedTiming)
    local sanitized = sanitizeMessageText(resolvedText)

    if not sanitized then
        return false, "message cannot be empty"
    end

    if #list >= MAX_MESSAGES then
        return false, "maximum messages reached"
    end

    list[#list + 1] = sanitized
    return true
end

function addon.ToonCelebrationService:UpdateMessage(kind, timing, index, text)
    local resolvedTiming, resolvedIndex, resolvedText = resolveUpdateMessageArgs(timing, index, text)
    local list = self:GetMessages(kind, resolvedTiming)
    local sanitized = sanitizeMessageText(resolvedText)

    if not sanitized then
        return false, "message cannot be empty"
    end

    if not resolvedIndex or resolvedIndex < 1 or resolvedIndex > #list then
        return false, "message selection is invalid"
    end

    list[resolvedIndex] = sanitized
    return true
end

function addon.ToonCelebrationService:DeleteMessage(kind, timing, index)
    local normalized = normalizeKind(kind)
    local resolvedTiming, resolvedIndex = resolveDeleteMessageArgs(timing, index)
    local fieldName = getMessageField(normalized, resolvedTiming)
    local list = self:GetMessages(normalized, resolvedTiming)

    if not resolvedIndex or resolvedIndex < 1 or resolvedIndex > #list then
        return false, "message selection is invalid"
    end

    table.remove(list, resolvedIndex)

    if #list == 0 then
        self:GetState()[fieldName] = self:GetDefaultMessages(normalized, resolvedTiming)
    end

    return true
end

function addon.ToonCelebrationService:ResetMessages(kind, timing)
    local normalized = normalizeKind(kind)
    local normalizedTiming = normalizeTiming(timing)
    local fieldName = getMessageField(normalized, normalizedTiming)

    self:GetState()[fieldName] = self:GetDefaultMessages(normalized, normalizedTiming)
    return self:GetState()[fieldName]
end

function addon.ToonCelebrationService:ResetAllDefaults()
    for _, kind in ipairs({ "birthday", "anniversary-month", "anniversary-six-month", "anniversary-year" }) do
        for _, timing in ipairs(MESSAGE_TIMINGS) do
            self:ResetMessages(kind, timing)
        end
    end
    self:ResetSettings()
end

function addon.ToonCelebrationService:GetRandomMessage(kind, timing)
    local list = self:GetMessages(kind, timing)

    if type(list) ~= "table" or #list == 0 then
        return nil
    end

    return list[math.random(1, #list)]
end

function addon.ToonCelebrationService:RecordSend(kind)
    local stats = self:GetState().stats
    local statField = getStatField(kind)

    stats[statField] = (stats[statField] or 0) + 1
    stats.lastSentAt = time()
end

function addon.ToonCelebrationService:WasSentForDate(historyKey, dayKey)
    return self:GetState().history[historyKey] == dayKey
end

function addon.ToonCelebrationService:MarkSentForDate(historyKey, dayKey)
    self:GetState().history[historyKey] = dayKey
    self:GetState().ignoredHistory[historyKey] = nil
end

function addon.ToonCelebrationService:WasIgnoredForDate(historyKey, dayKey)
    return self:GetState().ignoredHistory[historyKey] == dayKey
end

function addon.ToonCelebrationService:MarkIgnoredForDate(historyKey, dayKey)
    self:GetState().ignoredHistory[historyKey] = dayKey
end

function addon.ToonCelebrationService:WasSentToday(historyKey, dayKey)
    return self:WasSentForDate(historyKey, dayKey)
end

function addon.ToonCelebrationService:MarkSentToday(historyKey, dayKey)
    self:MarkSentForDate(historyKey, dayKey)
end

function addon.ToonCelebrationService:GetCurrentToonMember()
    local playerId = addon.Store:GetPlayerName()
    local currentGuildKey = addon.Store:GetGuildKey()

    if playerId == "" or not currentGuildKey or currentGuildKey == "" then
        return nil, nil
    end

    local member = addon.RosterService:GetMyToonMember(playerId) or addon.RosterService:GetPlayerMember()

    if not member or member.guildName ~= currentGuildKey then
        return nil, currentGuildKey
    end

    return member, currentGuildKey
end

function addon.ToonCelebrationService:ListCurrentGuildMyToons()
    local currentGuildKey = addon.Store:GetGuildKey()
    local toons = {}
    local seen = {}

    if not currentGuildKey or currentGuildKey == "" then
        return toons, nil
    end

    for _, member in ipairs(addon.RosterService:ListMyToons()) do
        if member and member.guildName == currentGuildKey and not member.deleted and not member.leftGuild and not seen[member.id] then
            seen[member.id] = true
            toons[#toons + 1] = member
        end
    end

    table.sort(toons, function(left, right)
        return trimString(left.fullName or left.id) < trimString(right.fullName or right.id)
    end)

    return toons, currentGuildKey
end

function addon.ToonCelebrationService:BuildCelebrationsForDate(member, guildKey, currentYear, currentMonth, currentDay, dayKey, includeSent)
    local celebrations = {}
    local settings = self:GetSettings()

    if settings.enableBirthday ~= false and isBirthdayOnDate(member and member.birthdate, currentMonth, currentDay) then
        local historyKey = buildHistoryKey("birthday", guildKey, member.id)

        if not self:WasIgnoredForDate(historyKey, dayKey) and (includeSent or not self:WasSentForDate(historyKey, dayKey)) then
            celebrations[#celebrations + 1] = {
                dayKey = dayKey,
                guildKey = guildKey,
                historyKey = historyKey,
                kind = "birthday",
                memberId = member.id,
                memberName = member.fullName or member.id,
                offsetDays = 0,
                years = 0,
            }
        end
    end

    if settings.enableAnniversary ~= false then
        local anniversary = getAnniversaryForDate(member and member.guildJoinDate, currentYear, currentMonth, currentDay)

        if anniversary then
            local historyKey = buildHistoryKey(anniversary.kind, guildKey, member.id)

            if not self:WasIgnoredForDate(historyKey, dayKey) and (includeSent or not self:WasSentForDate(historyKey, dayKey)) then
                anniversary.dayKey = dayKey
                anniversary.guildKey = guildKey
                anniversary.historyKey = historyKey
                anniversary.memberId = member.id
                anniversary.memberName = member.fullName or member.id
                anniversary.offsetDays = 0
                celebrations[#celebrations + 1] = anniversary
            end
        end
    end

    return celebrations
end

function addon.ToonCelebrationService:BuildDueCelebrations(member, guildKey)
    local currentYear, currentMonth, currentDay, dayKey = getTodayParts()
    return self:BuildCelebrationsForDate(member, guildKey, currentYear, currentMonth, currentDay, dayKey, false)
end

function addon.ToonCelebrationService:FormatMessage(kind, memberName, template, years, offsetDays)
    local text = trimString(template)

    if text == "" then
        return nil
    end

    text = text:gsub("{YEARS}", tostring(years or 0))

    return string.format("%s - %s", baseName(memberName) or "Guildmate", text)
end

function addon.ToonCelebrationService:FormatDateLabel(dayKey)
    local year, month, day = parseIsoDate(dayKey)

    if not year then
        return dayKey or "Unknown"
    end

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

function addon.ToonCelebrationService:GetTimingLabel(offsetDays)
    local offset = tonumber(offsetDays) or 0

    if offset == 0 then
        return "Today"
    end

    if offset < 0 then
        local days = math.abs(offset)
        return days == 1 and "1 day ago" or string.format("%d days ago", days)
    end

    return offset == 1 and "In 1 day" or string.format("In %d days", offset)
end

function addon.ToonCelebrationService:GetKindLabel(kind, years)
    local normalized = normalizeKind(kind)

    if normalized == "birthday" then
        return "Birthday"
    end

    if normalized == "anniversary-month" then
        return "1 Month"
    end

    if normalized == "anniversary-six-month" then
        return "6 Months"
    end

    return string.format("%d Year%s", tonumber(years) or 0, (tonumber(years) or 0) == 1 and "" or "s")
end

function addon.ToonCelebrationService:GetCelebrationWindow(lookbackDays, lookaheadDays)
    local entries = {}
    local toons, guildKey = self:ListCurrentGuildMyToons()
    local todayEpoch = time()
    local settings = self:GetSettings()
    local lookback = math.max(0, math.floor(tonumber(lookbackDays) or tonumber(settings.lookbackDays) or DEFAULT_LOOKBACK_DAYS))
    local lookahead = math.max(0, math.floor(tonumber(lookaheadDays) or tonumber(settings.lookaheadDays) or DEFAULT_LOOKAHEAD_DAYS))

    if not guildKey then
        return entries
    end

    for offset = -lookback, lookahead do
        local targetEpoch = todayEpoch + (offset * 86400)
        local targetDate = date("*t", targetEpoch)
        local dayKey = formatDayKey(targetDate.year, targetDate.month, targetDate.day)

        for _, member in ipairs(toons) do
            for _, celebration in ipairs(self:BuildCelebrationsForDate(member, guildKey, targetDate.year, targetDate.month, targetDate.day, dayKey, true)) do
                celebration.offsetDays = offset
                celebration.timingKey = getTimingForOffset(offset)
                celebration.kindLabel = self:GetKindLabel(celebration.kind, celebration.years)
                celebration.dateLabel = self:FormatDateLabel(dayKey)
                celebration.timingLabel = self:GetTimingLabel(offset)
                celebration.sent = self:WasSentForDate(celebration.historyKey, celebration.dayKey)
                celebration.id = string.format("%s|%s", celebration.historyKey, celebration.dayKey)
                entries[#entries + 1] = celebration
            end
        end
    end

    table.sort(entries, function(left, right)
        if (left.offsetDays or 0) == (right.offsetDays or 0) then
            if trimString(left.memberName or "") == trimString(right.memberName or "") then
                return trimString(left.kindLabel or "") < trimString(right.kindLabel or "")
            end

            return trimString(left.memberName or "") < trimString(right.memberName or "")
        end

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

    return entries
end

function addon.ToonCelebrationService:GetSummary(lookbackDays, lookaheadDays)
    local entries = self:GetCelebrationWindow(lookbackDays, lookaheadDays)
    local settings = self:GetSettings()
    local function countTemplates(kind)
        local total = 0

        for _, timing in ipairs(MESSAGE_TIMINGS) do
            total = total + #(self:GetMessages(kind, timing) or {})
        end

        return total
    end

    local summary = {
        total = #entries,
        ignored = 0,
        sent = 0,
        upcoming = 0,
        recent = 0,
        today = 0,
        guildKey = addon.Store:GetGuildKey(),
        settings = settings,
        birthdayCount = countTemplates("birthday"),
        anniversaryMonthCount = countTemplates("anniversary-month"),
        anniversarySixMonthCount = countTemplates("anniversary-six-month"),
        anniversaryYearCount = countTemplates("anniversary-year"),
        stats = self:GetState().stats or {},
    }

    for _, entry in ipairs(entries) do
        if entry.sent then
            summary.sent = summary.sent + 1
        end

        if (entry.offsetDays or 0) < 0 then
            summary.recent = summary.recent + 1
        elseif (entry.offsetDays or 0) > 0 then
            summary.upcoming = summary.upcoming + 1
        else
            summary.today = summary.today + 1
        end
    end

    return summary
end

function addon.ToonCelebrationService:GetPreview(kind, timing, text, years, offsetDays)
    local resolvedTiming = offsetDays == nil and normalizeTiming(timing) or getTimingForOffset(offsetDays)
    local sourceText = offsetDays == nil and sanitizeMessageText(text) or sanitizeMessageText(timing)

    if offsetDays == nil then
        offsetDays = years
        years = text
    end

    if not sourceText then
        sourceText = self:GetRandomMessage(kind, resolvedTiming) or "Type a message to preview it here."
    end

    return self:FormatMessage(kind, SAMPLE_PREVIEW_NAME, sourceText, years or (normalizeKind(kind) == "anniversary-year" and 3 or 0), offsetDays or 0)
end

function addon.ToonCelebrationService:IsDesignatedSender(celebration)
    if addon.GratsUService and addon.GratsUService.IsDesignatedSender then
        return addon.GratsUService:IsDesignatedSender(string.format("CELEB:%s:%s", celebration.historyKey or "?", celebration.dayKey or "?"))
    end

    return true
end

function addon.ToonCelebrationService:SendManualCelebration(celebration)
    if not self:CanAccess() then
        return false, "Only officers or the guild master can send celebration messages."
    end

    if not celebration or not celebration.historyKey or not celebration.dayKey then
        return false, "Celebration entry is invalid."
    end

    if self:WasSentForDate(celebration.historyKey, celebration.dayKey) then
        return false, "This celebration was already sent for that date."
    end

    if self:WasIgnoredForDate(celebration.historyKey, celebration.dayKey) then
        return false, "This celebration was already ignored for that date."
    end

    local currentGuildKey = addon.Store:GetGuildKey()

    if not currentGuildKey or currentGuildKey ~= celebration.guildKey then
        return false, "You must be logged into the same guild to send this celebration."
    end

    local message = self:GetRandomMessage(celebration.kind, getTimingForOffset(celebration.offsetDays))
    local formatted = message and self:FormatMessage(celebration.kind, celebration.memberName, message, celebration.years, celebration.offsetDays) or nil

    if not formatted then
        return false, "No celebration message is available."
    end

    if not sendGuild(formatted) then
        return false, "Unable to send the guild message right now."
    end

    self:MarkSentForDate(celebration.historyKey, celebration.dayKey)
    self:RecordSend(celebration.kind)
    return true
end

function addon.ToonCelebrationService:IgnoreCelebration(celebration)
    if not self:CanAccess() then
        return false, "Only officers or the guild master can ignore celebration messages."
    end

    if not celebration or not celebration.historyKey or not celebration.dayKey then
        return false, "Celebration entry is invalid."
    end

    if self:WasSentForDate(celebration.historyKey, celebration.dayKey) then
        return false, "This celebration was already sent for that date."
    end

    if self:WasIgnoredForDate(celebration.historyKey, celebration.dayKey) then
        return false, "This celebration was already ignored for that date."
    end

    local currentGuildKey = addon.Store:GetGuildKey()

    if not currentGuildKey or currentGuildKey ~= celebration.guildKey then
        return false, "You must be logged into the same guild to ignore this celebration."
    end

    self:MarkIgnoredForDate(celebration.historyKey, celebration.dayKey)
    return true
end

function addon.ToonCelebrationService:TrySendCelebration(celebration)
    local runtime = self:GetRuntime()
    runtime.pendingTimers[celebration.historyKey] = nil

    if (addon.IsSuspended and addon:IsSuspended()) or not self:CanAccess() or not IsInGuild or not IsInGuild() then
        return
    end

    local member, currentGuildKey = self:GetCurrentToonMember()

    if not member or currentGuildKey ~= celebration.guildKey or member.id ~= celebration.memberId then
        return
    end

    local dueCelebrations = self:BuildDueCelebrations(member, currentGuildKey)
    local matchedCelebration = nil

    for _, candidate in ipairs(dueCelebrations) do
        if candidate.historyKey == celebration.historyKey then
            matchedCelebration = candidate
            break
        end
    end

    if not matchedCelebration then
        return
    end

    if not self:IsDesignatedSender(matchedCelebration) then
        return
    end

    local message = self:GetRandomMessage(matchedCelebration.kind, getTimingForOffset(matchedCelebration.offsetDays))
    local formatted = message and self:FormatMessage(matchedCelebration.kind, matchedCelebration.memberName, message, matchedCelebration.years, matchedCelebration.offsetDays) or nil

    if formatted and sendGuild(formatted) then
        self:MarkSentForDate(matchedCelebration.historyKey, matchedCelebration.dayKey)
        self:RecordSend(matchedCelebration.kind)
    end
end

function addon.ToonCelebrationService:HandlePlayerLogin()
    if addon.IsSuspended and addon:IsSuspended() then
        return
    end

    self:EnsureDefaults()

    if not self:CanAccess() or not IsInGuild or not IsInGuild() then
        return
    end

    local _, guildKey = self:ListCurrentGuildMyToons()

    if not guildKey then
        return
    end

    local runtime = self:GetRuntime()
    local _, _, _, dayKey = getTodayParts()
    local reminderKey = string.format("%s|%s", trimString(guildKey), dayKey)

    if runtime.reminderKeys[reminderKey] then
        return
    end

    runtime.reminderKeys[reminderKey] = true

    local dueCount = 0

    for _, celebration in ipairs(self:GetCelebrationWindow(0, 0)) do
        if not celebration.sent then
            dueCount = dueCount + 1
        end
    end

    if dueCount > 0 then
        addon:Print(string.format("Toon Celebrations: %d message%s ready to send today for %s. Open the Toon Celebrations pane to send or ignore them.", dueCount, dueCount == 1 and "" or "s", guildKey))
    end
end