---@diagnostic disable: undefined-field, undefined-global
---@type string
local AddonName = ...
---@class Private
local Private = select(2, ...)
local wt = Private.Ace

-- The !meme feature (MEME-01..07, quick task 260728-50i): a raid-chat command that
-- shows the SAME randomly-chosen meme to every raid member, driven by a per-session
-- used-meme pool and, for the one animated entry, a FlipBook sprite-sheet animation.
--
-- Sits at the very bottom of WildThings.toc, after Advertisement.lua -- its only
-- load-time dependency is Init.lua (Private.commHandlers, via Private:RegisterCommEvent,
-- called below at file-load time). Private.Memes comes from the generated meme_data.lua,
-- loaded immediately above this file in the TOC -- pure data, no dependencies of its own.
-- WildThingsSavedVars is read at EVENT time only (inside HandleMemeComm, never at file
-- scope) -- SavedVars migrations run from wt:OnInitialize(), not at file load.
--
-- D-02 display: aspect-fit inside a MAX_BOX_WIDTH x MAX_BOX_HEIGHT box, centred on
-- UIParent, fade in/out. Deliberately NOT !ad's rotate+zoom+pulse treatment -- that
-- makes text memes unreadable. D-07: no sound at all.
-- D-03/D-04: the chat author rolls a meme from their own unused pool and broadcasts
-- the index; every receiver displays exactly that index and marks it used locally.
-- The pool is memory-only (module-local, never SavedVars) and resets once every meme
-- has been shown once.
-- D-06/MEME-03: InCombatLockdown() gates both the send path (OnMemeCommand) and the
-- receive path (HandleMemeComm) -- a receiver discards rather than queues.

local DISPLAY_DURATION = 7
local COMMAND_COOLDOWN = 7
local MAX_BOX_WIDTH = 600
local MAX_BOX_HEIGHT = 500
local FADE_IN = 0.25
local FADE_OUT = 0.35
-- Theme.ICON_PATH_FORMAT (UI/Settings.lua) proves this repo's PNG texture paths carry
-- the .png extension -- extensionless resolution works for .jpg/.blp, not .png. Static
-- entries are JPEG and the sprite sheet is PNG (generate_meme_sprites.py's format
-- decision -- JPEG's block artifacts would bleed across flipbook cell boundaries), so
-- the extension is NOT hardcoded here: entry.ext (generator-emitted, never inferred
-- from entry.frames) fills the second %s.
local MEME_PATH_FORMAT = "Interface\\AddOns\\%s\\Media\\Images\\MemePool\\%s.%s"

-- Session pool (D-03, MEME-04): memory only, reset by /reload (module-local, never
-- written to WildThingsSavedVars).
local usedMemes = {}
local usedCount = 0

-- Shared send+receive cooldown timestamp (Claude's Discretion, CONTEXT.md): the addon
-- channel is spoofable (per CLAUDE.md), so the receive path needs its own rate limit
-- regardless of the sender's own cooldown -- one shared GetTime() timestamp covers both.
local lastMemeAt = 0

-- The single reused display frame + its animation groups, created lazily on first show.
local displayFrame, displayTexture, closeButton
local flipGroup, flipAnim
local fadeInGroup, fadeInAnim
local fadeOutGroup, fadeOutAnim
local hideTimer

-- MarkMemeUsed(index): flags index used, resets the whole pool (MEME-04) once every
-- meme in Private.Memes has been shown once -- so the pool is available again
-- immediately after the last meme is consumed.
local function MarkMemeUsed(index)
    if not usedMemes[index] then
        usedMemes[index] = true
        usedCount = usedCount + 1
    end
    if Private.Memes and usedCount >= #Private.Memes then
        usedMemes = {}
        usedCount = 0
    end
end

-- RollUnusedIndex(): picks a random index from Private.Memes that has not been shown
-- this session. Resets first if every meme is already used (defensive -- MarkMemeUsed
-- already resets at the boundary; this covers any caller that shows without marking).
-- Returns nil when Private.Memes is empty.
local function RollUnusedIndex()
    if not Private.Memes or #Private.Memes == 0 then return nil end

    -- No math.randomseed here, deliberately: it does not exist in the WoW Lua
    -- sandbox (nil field on the math table -- undefined-field, which every file's
    -- diagnostic header suppresses, so check.ps1 cannot see this). Calling it is a
    -- guaranteed runtime error, not a no-op. The client seeds math.random for us at
    -- startup; PartyMode.lua:65/71 and Timeline.lua's reply-delay timer already rely
    -- on exactly that, unseeded. Do not re-add a seeding call here.

    local candidates = {}
    for index in ipairs(Private.Memes) do
        if not usedMemes[index] then
            candidates[#candidates + 1] = index
        end
    end

    if #candidates == 0 then
        usedMemes = {}
        usedCount = 0
        for index in ipairs(Private.Memes) do
            candidates[#candidates + 1] = index
        end
    end

    return candidates[math.random(#candidates)]
end

-- DismissMeme(): the single teardown path. Both the DISPLAY_DURATION timer and the
-- close button run through here, so a manual dismiss cannot leave a pending timer
-- that fires a second fade-out on top of it.
local function DismissMeme()
    if hideTimer then
        hideTimer:Cancel()
        hideTimer = nil
    end
    if not displayFrame or not displayFrame:IsShown() then return end
    if fadeOutGroup:IsPlaying() then return end
    fadeOutGroup:Play()
end

-- EnsureDisplayFrame(): lazily builds the single reused display frame, its texture,
-- its three AnimationGroups (flipbook, fade-in, fade-out) and the close button.
-- Created once; every later Private:ShowMeme call reconfigures and replays the same
-- objects.
local function EnsureDisplayFrame()
    if displayFrame then return end

    displayFrame = CreateFrame("Frame", nil, UIParent)
    displayFrame:SetFrameStrata("HIGH")
    displayFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    displayFrame:Hide()

    displayTexture = displayFrame:CreateTexture(nil, "ARTWORK")
    displayTexture:SetAllPoints()

    -- FlipBook group lives on the texture itself -- no SetTarget needed for that form.
    flipGroup = displayTexture:CreateAnimationGroup()
    flipGroup:SetLooping("REPEAT")
    flipAnim = flipGroup:CreateAnimation("FlipBook")
    flipAnim:SetOrder(1)
    if flipAnim.SetSmoothing then
        flipAnim:SetSmoothing("NONE")
    end

    -- Fade lives on the FRAME, never the texture, so it cannot interact with the
    -- flipbook's texcoord animation (D-02).
    -- SetToFinalAlpha is an AnimationGroup method, NOT an Animation method -- calling
    -- it on the Animation object compiles, passes every static gate, and does
    -- nothing forever (a stale existence guard around it just hides that it never
    -- ran). Every real call site in the installed-addon corpus targets the GROUP:
    -- LibDBIcon-1.0.lua:295-301 creates the Animation via
    -- button.fadeOut:CreateAnimation("Alpha") but calls SetToFinalAlpha on
    -- button.fadeOut itself (the group). No existence guard here on purpose -- the
    -- method genuinely exists on both groups, and a guard that silently swallows a
    -- wrong-type call is exactly the bug class this comment exists to prevent from
    -- coming back.
    fadeInGroup = displayFrame:CreateAnimationGroup()
    fadeInGroup:SetToFinalAlpha(true)
    fadeInAnim = fadeInGroup:CreateAnimation("Alpha")
    fadeInAnim:SetFromAlpha(0)
    fadeInAnim:SetToAlpha(1)
    fadeInAnim:SetDuration(FADE_IN)
    fadeInAnim:SetOrder(1)
    -- Belt and braces: force the held-visible alpha explicitly rather than relying
    -- solely on SetToFinalAlpha's revert-suppression semantics, which are not
    -- guaranteed identical across every client build.
    fadeInGroup:SetScript("OnFinished", function()
        Private.safecall(function()
            displayFrame:SetAlpha(1)
        end)
    end)

    fadeOutGroup = displayFrame:CreateAnimationGroup()
    fadeOutGroup:SetToFinalAlpha(true)
    fadeOutAnim = fadeOutGroup:CreateAnimation("Alpha")
    fadeOutAnim:SetFromAlpha(1)
    fadeOutAnim:SetToAlpha(0)
    fadeOutAnim:SetDuration(FADE_OUT)
    fadeOutAnim:SetOrder(1)
    fadeOutGroup:SetScript("OnFinished", function()
        Private.safecall(function()
            -- Explicit alpha reset (not just Hide()) so the reused frame starts the
            -- NEXT ShowMeme from a deterministic state regardless of group-revert
            -- behaviour -- belt and braces, same reasoning as fadeInGroup above.
            displayFrame:SetAlpha(0)
            displayFrame:Hide()
            if flipGroup:IsPlaying() then
                flipGroup:Stop()
            end
        end)
    end)

    -- Close button, top-right corner of the image: dismiss a meme before its 7s are
    -- up. CreateIconButton is the canonical widget (UI/Widgets/IconButton.lua, loaded
    -- from UI.xml:16 -- well above the do-not-package fence, so it ships in every
    -- build) and is what ChromeWindow's own close button uses. Safe to call here
    -- because EnsureDisplayFrame only ever runs on the first meme display, long after
    -- WTUI:Initialize.
    closeButton = Private:CreateIconButton(displayFrame, "x", function()
        Private.safecall(DismissMeme)
    end)
    closeButton:SetPoint("TOPRIGHT", displayFrame, "TOPRIGHT", -2, -2)
    -- Above the ARTWORK-layer meme texture: a child Button already draws over its
    -- parent's textures, but the meme frame is reused and restacked on every show,
    -- so pin the level explicitly rather than relying on creation order.
    closeButton:SetFrameLevel(displayFrame:GetFrameLevel() + 10)
end

-- Private:ShowMeme(index): the single display path -- used by the chat author
-- directly (DispatchCommEvent drops self-messages centrally, Transmission.lua:79,
-- so there is no round trip), by the MEME comm handler, and by /wt memetest.
function Private:ShowMeme(index)
    local entry = Private.Memes and Private.Memes[index]
    if not entry then return end -- a different addon version may not have this index

    EnsureDisplayFrame()

    if hideTimer then
        hideTimer:Cancel()
        hideTimer = nil
    end
    -- !meme can legitimately re-fire every COMMAND_COOLDOWN seconds -- stop
    -- everything before re-showing.
    if fadeInGroup:IsPlaying() then fadeInGroup:Stop() end
    if fadeOutGroup:IsPlaying() then fadeOutGroup:Stop() end
    if flipGroup:IsPlaying() then flipGroup:Stop() end

    -- Aspect-fit (D-02): entry.width/height are the animated entry's CELL size, so
    -- this one expression is correct for both static and animated entries.
    local scale = math.min(MAX_BOX_WIDTH / entry.width, MAX_BOX_HEIGHT / entry.height)
    displayFrame:SetSize(entry.width * scale, entry.height * scale)

    local texturePath = MEME_PATH_FORMAT:format(AddonName, entry.stem, entry.ext)

    if entry.frames then
        -- Animated entry (MEME-01, D-01): the FlipBook drives the texcoords itself --
        -- never call SetTexCoord while it is playing.
        displayTexture:SetTexture(texturePath)
        flipAnim:SetDuration(entry.duration)
        flipAnim:SetFlipBookRows(entry.rows)
        flipAnim:SetFlipBookColumns(entry.columns)
        flipAnim:SetFlipBookFrames(entry.frames)
        -- 0/0 = "divide the whole texture evenly by columns/rows" -- valid only
        -- because generate_meme_sprites.py asserted the sheet divides exactly.
        flipAnim:SetFlipBookFrameWidth(0)
        flipAnim:SetFlipBookFrameHeight(0)
        flipGroup:Play()
    else
        -- Static entry: reset texcoords BEFORE SetTexture -- a FlipBook leaves them
        -- wherever it stopped, so without this a static meme shown after the
        -- animated one renders as a crop of one cell.
        displayTexture:SetTexCoord(0, 1, 0, 1)
        displayTexture:SetTexture(texturePath)
    end

    displayFrame:SetAlpha(0)
    displayFrame:Show()
    fadeInGroup:Play()

    -- Total on-screen time is DISPLAY_DURATION (MEME-05): fade-out starts early
    -- enough that it finishes exactly at DISPLAY_DURATION.
    hideTimer = C_Timer.NewTimer(DISPLAY_DURATION - FADE_OUT, function()
        Private.safecall(function()
            hideTimer = nil -- cleared first so DismissMeme does not Cancel a fired timer
            DismissMeme()
        end)
    end)
end

-- Private.OnMemeCommand(...): the author-side entry point. Every client receives the
-- chat event (Core.lua delegates the full varargs here); only the author acts (D-04).
function Private.OnMemeCommand(...)
    -- select(12, ...) is the chat event's GUID argument -- opaque, globally unique,
    -- and the direct equality partner of UnitGUID("player"). The name fallback exists
    -- because arg 2 arrives realm-qualified for cross-realm senders and this is a
    -- cross-realm-guild tool; the GUID path is the one that runs.
    local authorGUID = select(12, ...)
    local authorName = select(2, ...)
    -- Midnight secret values: both identity fields are compared below, which
    -- raises on a secret. Core.lua's guard only clears the message text, so this
    -- one has to stand here, next to its own uses. It can never suppress the
    -- author's own command -- the local player's identity is never secret.
    if Private.HasSecretValue(authorGUID, authorName) then return end

    local isAuthor
    if authorGUID and authorGUID ~= "" then
        isAuthor = (authorGUID == UnitGUID("player"))
    else
        isAuthor = authorName ~= nil and Ambiguate(authorName, "none") == UnitName("player")
    end
    if not isAuthor then return end

    if InCombatLockdown() then return end -- D-06, MEME-03

    if (GetTime() - lastMemeAt) < COMMAND_COOLDOWN then return end -- MEME-06

    local index = RollUnusedIndex()
    if not index then return end
    MarkMemeUsed(index)
    lastMemeAt = GetTime()

    -- Only broadcast when actually grouped -- CHAT_MSG_YELL can fire while ungrouped,
    -- and sending on a channel the player is not in is a pointless error path (D-05
    -- locks the raid case; PARTY is this discretion's extension of the same idea).
    if IsInRaid() then
        Private:Broadcast("MEME", "RAID", index)
    elseif IsInGroup() then
        Private:Broadcast("MEME", "PARTY", index)
    end

    -- The author must display locally -- DispatchCommEvent drops self-messages
    -- centrally (Transmission.lua:79), so there is no round trip back to us.
    Private:ShowMeme(index)
end

-- HandleMemeComm(sender, index): the MEME comm handler body. Sender identity and
-- raid membership are already enforced upstream by ValidateCommSender
-- (Transmission.lua:66) -- this does not re-derive or re-check either. The payload
-- is untrusted input regardless (the addon channel is spoofable, per CLAUDE.md).
local function HandleMemeComm(sender, index)
    if not WildThingsSavedVars.update.memes then return end
    if type(index) ~= "number" then return end -- crafted/malformed payload
    if InCombatLockdown() then return end -- D-06, MEME-03: discard, never queue

    if (GetTime() - lastMemeAt) < COMMAND_COOLDOWN then return end -- MEME-06, T-50i-03

    lastMemeAt = GetTime()
    MarkMemeUsed(index) -- keeps the local pool in step (D-03)
    Private:ShowMeme(index) -- bounds-checks index internally; silent no-op if absent
end

-- Registered at file-load time (only needs Private.commHandlers from Init.lua, which
-- loads far above this file). DispatchCommEvent invokes handlers with a plain call
-- and no error containment, so the registered closure wraps the real handler in
-- Private.safecall itself.
Private:RegisterCommEvent("MEME", function(sender, ...)
    Private.safecall(HandleMemeComm, sender, ...)
end)

-- Private:MemeTest(mode): solo dev harness (the /wt summonstest / inspecttest /
-- feasttest precedent). The comm path cannot be exercised on one client by
-- construction (DispatchCommEvent drops self-messages centrally), so this drives
-- Private:ShowMeme directly and sends nothing.
--   (no mode)   -- rolls+marks an unused index from the session pool and shows it,
--                  the same roll/mark pipeline as the real command (pool coverage)
--   <number>    -- shows that exact index directly, no pool interaction
--   "gif"       -- shows the first entry carrying `frames` (the animated entry)
--   "pool"      -- prints used-vs-total, no roll, no display
function Private:MemeTest(mode)
    if not Private.Memes or #Private.Memes == 0 then
        print("|cff99ff00Wild Things:|r ", "memetest: Private.Memes is empty.")
        return
    end

    if mode == "pool" then
        print(("|cff99ff00Wild Things:|r  memetest pool: %d/%d used"):format(usedCount, #Private.Memes))
        return
    end

    if mode == "gif" then
        for index, entry in ipairs(Private.Memes) do
            if entry.frames then
                print(("|cff99ff00Wild Things:|r  memetest: showing animated #%d (%s)"):format(index, entry.stem))
                Private:ShowMeme(index)
                return
            end
        end
        print("|cff99ff00Wild Things:|r ", "memetest: no animated entry found.")
        return
    end

    local numericIndex = mode and tonumber(mode)
    if numericIndex then
        print(("|cff99ff00Wild Things:|r  memetest: showing #%d"):format(numericIndex))
        Private:ShowMeme(numericIndex)
        return
    end

    local index = RollUnusedIndex()
    if not index then
        print("|cff99ff00Wild Things:|r ", "memetest: no memes available to roll.")
        return
    end
    MarkMemeUsed(index)
    print(("|cff99ff00Wild Things:|r  memetest: rolled #%d (%s)"):format(index, Private.Memes[index].stem))
    Private:ShowMeme(index)
end
