local _, ns = ...

------------------------------------------------------------
-- L'ura (Midnight Falls) memory-game helper.
--
-- Protocol (pattern matched to NSRT, but with our bundled
-- symbol textures instead of Blizzard iconIDs):
--
--   - 5 macros `YY_Lura_1..5`. Each macro body is literally
--     `/raid <texture-path>`, where the path points at one of
--     our bundled symbol TGAs inside the addon folder.
--   - Every YippYappHelper client listens on CHAT_MSG_RAID_LEADER
--     during encounter memory-game windows. Raid-leader-only so
--     random raid chatter can't drive the pentagon; only the
--     designated caller's macros fire the display. On receipt,
--     `msg` is passed STRAIGHT into
--     SetFormattedText("|T%s:48:48|t", msg). We never call
--     msg:match / msg:sub / msg:find / msg:lower.
--
-- Why the no-index rule matters:
--   During Midnight encounters the chat payload arrives as a
--   "secret string value" — a wrapped Lua string whose
--   contents are hidden from addons. Any index operation
--   trips taint and crashes the handler (as the v2.0.x build
--   did). SetFormattedText's `%s` tolerates secret strings
--   because NSRT's live build proves it does.
--
-- Why our symbols (not Blizzard iconIDs):
--   Every client with YippYappHelper installed already has the
--   TGAs at `Interface\AddOns\YippYappHelper\Media\Textures\`.
--   No `Interface\ICONS\` override hack, no Blizzard icon
--   replacement, no extra install step. The trade-off is that
--   non-YippYapp raiders see a filename in chat and NSRT-only
--   raiders don't render a pentagon — intentional, since this
--   is a guild-only feature.
------------------------------------------------------------

ns.Lura = ns.Lura or {}
local L = ns.Lura

------------------------------------------------------------
-- Encounter config
------------------------------------------------------------

local ENCOUNTER_ID = 3183  -- L'ura / Midnight Falls

-- Memory-game offsets (seconds) from ENCOUNTER_START. Cribbed
-- from NSRT's MidnightFalls.lua so our windows line up with
-- when the boss actually shows the runes.
local MEMGAME_TIMERS = {
    [14] = { 10,  80, 150 },  -- Normal
    [15] = { 10,  80, 150 },  -- Heroic
    [16] = { 33,  95, 157 },  -- Mythic
}
-- Phase-4 additions (mythic only). NSRT detects the phase swap
-- via INSTANCE_ENCOUNTER_ENGAGE_UNIT; we approximate by
-- scheduling relative to a fixed P4 start.
local P4_BASE = 180
local P4_TIMERS = { 20, 40, 75, 95, 130, 150 }

-- How early to arm the listener before a scheduled window
local PRE_ARM = 2
-- Hide + disarm after this much idle with no rune arrival
local IDLE_HIDE_DELAY = 15

------------------------------------------------------------
-- Protocol — the 5 bundled symbol textures, one per slot
--
-- The chat payload is just the short filename suffix (e.g.
-- "circle"). Every client's receiver bakes the shared path
-- prefix into its `SetFormattedText` format string, so the
-- wire payload stays small and the receiver never has to
-- concat the secret-string `msg` with anything.
------------------------------------------------------------

local TEX_ROOT   = "Interface\\AddOns\\YippYappHelper\\Media\\Textures\\"
local TEX_PREFIX = TEX_ROOT .. "symbol_"  -- baked into receiver's format string

local SLOT_CODES  = { "circle", "diamond", "t", "triangle", "x" }
local SLOT_LABELS = { "Circle", "Diamond", "T", "Triangle", "X" }

local function FullTexturePath(i)
    return TEX_PREFIX .. SLOT_CODES[i]
end

-- Pentagon slot positions (press-order fill: first press → slot 1)
local SLOT_POS = {
    { x =  65, y =  40 },  -- 1 upper-right
    { x =  50, y = -40 },  -- 2 lower-right
    { x =   0, y = -65 },  -- 3 bottom
    { x = -50, y = -40 },  -- 4 lower-left
    { x = -65, y =  40 },  -- 5 upper-left
}

------------------------------------------------------------
-- SavedVariables
------------------------------------------------------------

local DEFAULTS = {
    enabled             = true,
    -- When true (default), the raid-chat listener is only armed during
    -- scheduled L'ura memory-game windows. When false, it stays armed
    -- whenever the feature is enabled — fallback in case encounter
    -- detection fails (zone changes, /reload mid-pull, etc.).
    restrictToEncounter = true,
    iconSize            = 48,
    -- Fresh installs start unlocked so the player can immediately drag
    -- the pentagon into place via the attached Lock button. Existing
    -- saved values (true or false) are preserved by db().
    locked              = false,
    position            = { anchor = "CENTER", relativePoint = "CENTER", x = 0, y = 150 },
}

local function db()
    YippYappHelperDB = YippYappHelperDB or {}
    local d = YippYappHelperDB.lura
    if type(d) ~= "table" then
        d = {}
        YippYappHelperDB.lura = d
    end
    if type(d.enabled)             ~= "boolean" then d.enabled             = DEFAULTS.enabled end
    if type(d.restrictToEncounter) ~= "boolean" then d.restrictToEncounter = DEFAULTS.restrictToEncounter end
    if type(d.iconSize)            ~= "number" or d.iconSize <= 0 then d.iconSize = DEFAULTS.iconSize end
    if type(d.locked)              ~= "boolean" then d.locked              = DEFAULTS.locked end
    if type(d.position) ~= "table" then
        d.position = {
            anchor = DEFAULTS.position.anchor,
            relativePoint = DEFAULTS.position.relativePoint,
            x = DEFAULTS.position.x,
            y = DEFAULTS.position.y,
        }
    end
    return d
end

------------------------------------------------------------
-- State
------------------------------------------------------------

local display
local chatFrame
local filled         = {}
local slotDisplays   = {}  -- FontStrings (render inline texture via SetFormattedText)
local slotNumbers    = {}
local hideTimer
local simTimers      = {}
local armTimers      = {}
local encounterArmed = false  -- true during L'ura encounter (windowed arming)

------------------------------------------------------------
-- Display frame
------------------------------------------------------------

local function ApplyPosition()
    if not display then return end
    local d = db()
    display:ClearAllPoints()
    display:SetPoint(d.position.anchor, UIParent, d.position.relativePoint, d.position.x, d.position.y)
end

-- Mirrors lock state onto the display frame: mouse enabled + Lock button
-- visible when unlocked; mouse disabled + button hidden when locked.
local function ApplyLockState()
    if not display then return end
    local locked = db().locked
    display:EnableMouse(not locked)
    if display.lockBtn then
        if locked then display.lockBtn:Hide() else display.lockBtn:Show() end
    end
end

local function EnsureDisplay()
    if display then return display end
    display = CreateFrame("Frame", "YYH_LuraDisplay", UIParent, "BackdropTemplate")
    display:SetSize(200, 200)
    display:SetFrameStrata("MEDIUM")
    display:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    display:SetBackdropColor(0, 0, 0, 0.55)
    display:SetBackdropBorderColor(0.25, 0.25, 0.25, 0.9)
    display:SetMovable(true)
    display:RegisterForDrag("LeftButton")
    display:SetScript("OnDragStart", function(self)
        if not db().locked then self:StartMoving() end
    end)
    display:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        local point, _, relPoint, x, y = self:GetPoint()
        local d = db()
        d.position.anchor = point
        d.position.relativePoint = relPoint
        d.position.x = x
        d.position.y = y
    end)

    -- Attached Lock button. Anchored just above the frame so it never
    -- overlaps the pentagon slots, and hidden when locked so the raid
    -- UI is clean during encounters.
    local lockBtn = CreateFrame("Button", nil, display, "BackdropTemplate")
    lockBtn:SetSize(60, 18)
    lockBtn:SetPoint("BOTTOM", display, "TOP", 0, 2)
    lockBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 8,
        insets   = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    lockBtn:SetBackdropColor(0.10, 0.10, 0.10, 0.95)
    lockBtn:SetBackdropBorderColor(0.35, 0.35, 0.35, 0.9)
    local lockLabel = lockBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    lockLabel:SetPoint("CENTER")
    lockLabel:SetText("Lock")
    lockBtn:SetScript("OnEnter", function(self)
        self:SetBackdropBorderColor(0.0, 0.8, 1.0, 1)
    end)
    lockBtn:SetScript("OnLeave", function(self)
        self:SetBackdropBorderColor(0.35, 0.35, 0.35, 0.9)
    end)
    lockBtn:SetScript("OnClick", function()
        L:SetLocked(true)
        L:Clear()
    end)
    display.lockBtn = lockBtn

    ApplyPosition()
    ApplyLockState()
    display:Hide()
    return display
end

local function EnsureSlot(i)
    EnsureDisplay()
    if slotDisplays[i] then return end
    local size = db().iconSize

    -- FontString receives the raw msg via SetFormattedText — no
    -- Texture:SetTexture(msg) path, because we must never touch
    -- the secret-string contents (indexing would crash).
    local fs = display:CreateFontString(nil, "OVERLAY")
    fs:SetFont("Fonts\\FRIZQT__.TTF", 15)
    fs:SetTextColor(1, 1, 1, 1)
    fs:SetPoint("CENTER", display, "CENTER", SLOT_POS[i].x, SLOT_POS[i].y)
    slotDisplays[i] = fs

    local num = display:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    num:SetPoint("CENTER", fs, "CENTER", 0, size * 0.55 + 6)
    num:SetTextColor(1, 1, 0.3, 1)
    num:SetText(tostring(i))
    slotNumbers[i] = num

    fs:Hide()
    num:Hide()
end

------------------------------------------------------------
-- Arming / disarming the chat listener
------------------------------------------------------------

local function EnsureChatFrame()
    if chatFrame then return chatFrame end
    chatFrame = CreateFrame("Frame", "YYH_LuraChat")
    chatFrame:SetScript("OnEvent", function(_, event, msg)
        if event ~= "CHAT_MSG_RAID_LEADER"
           and event ~= "CHAT_MSG_SAY"
        then
            return
        end
        if msg == nil then return end
        -- DO NOT index msg. During Midnight encounters it's a
        -- secret-string value; any :match / :sub / :find / :lower
        -- trips taint. SetFormattedText's %s tolerates it.
        L._RenderMsg(msg)
    end)
    return chatFrame
end

local function ArmRaid()
    EnsureChatFrame()
    chatFrame:RegisterEvent("CHAT_MSG_RAID_LEADER")
end

local function DisarmRaid()
    if not chatFrame then return end
    chatFrame:UnregisterEvent("CHAT_MSG_RAID_LEADER")
end

local function CancelArmTimers()
    for _, t in ipairs(armTimers) do
        if t and t.Cancel then t:Cancel() end
    end
    wipe(armTimers)
end

------------------------------------------------------------
-- Rendering
------------------------------------------------------------

local function HideAll()
    if hideTimer then hideTimer:Cancel(); hideTimer = nil end
    if not display then return end
    for i = 1, 5 do
        if slotDisplays[i] then slotDisplays[i]:Hide() end
        if slotNumbers[i]  then slotNumbers[i]:Hide()  end
    end
    wipe(filled)
    display:Hide()
    -- In strict mode (restrictToEncounter), idle-hide also disarms
    -- so random /raid chatter between memory games doesn't keep
    -- pumping the pentagon. In always-listen mode, keep listening.
    if encounterArmed and db().restrictToEncounter then
        DisarmRaid()
    end
end

local function ScheduleHide()
    if hideTimer then hideTimer:Cancel() end
    -- While unlocked the user is actively positioning the frame — don't
    -- yank it out from under them after the idle delay. Lock re-engages
    -- the normal hide behavior.
    if not db().locked then hideTimer = nil; return end
    hideTimer = C_Timer.NewTimer(IDLE_HIDE_DELAY, HideAll)
end

local function NextFreeSlot()
    for i = 1, 5 do
        if not filled[i] then return i end
    end
    return nil
end

-- Public render entrypoint, exposed as L._RenderMsg for the
-- chat handler's upvalue-safe reach.
--
-- `msg` is the short suffix ("circle", "diamond", …). The path
-- prefix is baked into the format string — we never concatenate
-- or index `msg`, which keeps Midnight's secret-string wrapping
-- safe through the whole pipeline.
function L._RenderMsg(msg)
    local slot = NextFreeSlot()
    if not slot then
        HideAll()
        slot = 1
    end
    EnsureSlot(slot)
    local size = db().iconSize
    slotDisplays[slot]:SetFormattedText(
        "|T" .. TEX_PREFIX .. "%s:" .. size .. ":" .. size .. "|t", msg)
    slotDisplays[slot]:Show()
    slotNumbers[slot]:Show()
    filled[slot] = true
    display:Show()
    ScheduleHide()
end

------------------------------------------------------------
-- Enable / apply
------------------------------------------------------------

local function ApplyEnabled()
    local d = db()
    if not d.enabled then
        CancelArmTimers()
        encounterArmed = false
        DisarmRaid()
        HideAll()
        return
    end
    -- Enabled. Two modes:
    --   restrictToEncounter = true  → listener only armed inside the
    --                                 L'ura encounter's memory-game
    --                                 windows. ApplyEnabled leaves
    --                                 it disarmed out of encounter;
    --                                 the encounter handler owns
    --                                 the windowed arming.
    --   restrictToEncounter = false → listener is always armed.
    --                                 Fallback for cases where the
    --                                 encounter detection misses (eg
    --                                 /reload mid-pull).
    if d.restrictToEncounter then
        if not encounterArmed then DisarmRaid() end
    else
        ArmRaid()
    end
end

------------------------------------------------------------
-- Encounter lifecycle
------------------------------------------------------------

local function ScheduleWindow(tSeconds)
    local armAt = math.max(tSeconds - PRE_ARM, 0)
    table.insert(armTimers, C_Timer.NewTimer(armAt, ArmRaid))
end

local encounterFrame = CreateFrame("Frame", "YYH_LuraEncounter")
encounterFrame:RegisterEvent("ENCOUNTER_START")
encounterFrame:RegisterEvent("ENCOUNTER_END")

encounterFrame:SetScript("OnEvent", function(_, event, encID, _, difficultyID)
    if event == "ENCOUNTER_START" and encID == ENCOUNTER_ID then
        HideAll()
        CancelArmTimers()
        if not db().enabled then return end
        encounterArmed = true
        if db().restrictToEncounter then
            -- Strict mode: start disarmed; scheduled arm-timers bring
            -- the listener back at each memory-game window. Idle-hide
            -- disarms after each window.
            DisarmRaid()
            local timers = MEMGAME_TIMERS[difficultyID] or MEMGAME_TIMERS[15]
            for _, t in ipairs(timers) do ScheduleWindow(t) end
            if difficultyID == 16 then
                for _, t in ipairs(P4_TIMERS) do ScheduleWindow(P4_BASE + t) end
            end
        else
            -- Always-listen mode: listener is already armed via
            -- ApplyEnabled. No windowing needed.
            ArmRaid()
        end
    elseif event == "ENCOUNTER_END" and encID == ENCOUNTER_ID then
        CancelArmTimers()
        encounterArmed = false
        HideAll()
        -- Out of encounter: honor restrictToEncounter — off in strict
        -- mode, back on in always-listen mode.
        ApplyEnabled()
    end
end)

------------------------------------------------------------
-- Macro generator
--
-- Matches NSRT's pattern: 3-arg CreateMacro with a full texture
-- path as the icon argument (path, not a name string). Existing
-- YY_Lura_* / YY_* macros are deleted first so a broken prior
-- save can't land on your bar.
------------------------------------------------------------

local MACRO_PREFIX = "YY_Lura_"
local STALE_NAMES  = { "YY_1", "YY_2", "YY_3", "YY_4", "YY_5" }

-- Shared icon for all 5 macros. Numeric FileDataID — addon-folder
-- paths render transparently on the macro slot because the macro
-- system resolves icons against Interface\ICONS\ and FileDataIDs,
-- not arbitrary addon paths.
local MACRO_ICON_ID = 134328

local function DeleteStale()
    for _, n in ipairs(STALE_NAMES) do
        local idx = GetMacroIndexByName(n)
        if idx and idx > 0 then DeleteMacro(idx) end
    end
    for i = 1, 5 do
        local idx = GetMacroIndexByName(MACRO_PREFIX .. i)
        if idx and idx > 0 then DeleteMacro(idx) end
    end
end

function L:CreateMacros()
    if InCombatLockdown() then
        print("|cff00ff00YippYapp|r: can't create macros in combat; try again after the pull.")
        return
    end

    DeleteStale()

    local created = 0
    for i, code in ipairs(SLOT_CODES) do
        local name = MACRO_PREFIX .. i
        local body = "/raid " .. code                  -- e.g. "/raid circle"
        local numGlobal, numChar = GetNumMacros()
        local perChar = numGlobal >= MAX_ACCOUNT_MACROS
        if perChar and numChar >= MAX_CHARACTER_MACROS then
            print("|cff00ff00YippYapp|r: no free macro slots for " .. name)
            break
        end
        CreateMacro(name, MACRO_ICON_ID, body, perChar and 1 or nil)
        created = created + 1
    end
    print(("|cff00ff00YippYapp|r: L'ura macros ready (%d macros). Drag YY_Lura_1..5 onto your bars."):format(created))
end

------------------------------------------------------------
-- Simulation / preview
------------------------------------------------------------

local function CancelSim()
    for _, t in ipairs(simTimers) do
        if t and t.Cancel then t:Cancel() end
    end
    wipe(simTimers)
end

function L:Preview()
    HideAll()
    EnsureDisplay()
    local size = db().iconSize
    for i, code in ipairs(SLOT_CODES) do
        EnsureSlot(i)
        slotDisplays[i]:SetFormattedText(
            "|T" .. TEX_PREFIX .. "%s:" .. size .. ":" .. size .. "|t", code)
        slotDisplays[i]:Show()
        slotNumbers[i]:Show()
        filled[i] = true
    end
    display:Show()
    ScheduleHide()
end

-- Route fake payloads through the real chat handler so we test
-- the exact receive path a live CHAT_MSG_RAID_LEADER would take.
function L:Test()
    CancelSim()
    HideAll()
    local order = { 3, 1, 5, 2, 4 }
    for i, slot in ipairs(order) do
        local t = C_Timer.NewTimer(0.6 * i, function()
            L._RenderMsg(SLOT_CODES[slot])
        end)
        table.insert(simTimers, t)
    end
end

------------------------------------------------------------
-- Solo end-to-end test via /say (works without a raid)
------------------------------------------------------------

function L:SetSayDebug(on)
    EnsureChatFrame()
    if on then
        chatFrame:RegisterEvent("CHAT_MSG_SAY")
        print("|cff00ff00YippYapp|r: L'ura /say debug |cff00ff00ON|r. Type /say <anything> — or /yh lura saytest.")
    else
        chatFrame:UnregisterEvent("CHAT_MSG_SAY")
        print("|cff00ff00YippYapp|r: L'ura /say debug |cffff0000OFF|r.")
    end
end

function L:SayTest()
    if InCombatLockdown() then
        print("|cff00ff00YippYapp|r: can't SendChatMessage from combat. Move out of combat and retry.")
        return
    end
    CancelSim()
    HideAll()
    local order = { 3, 1, 5, 2, 4 }
    for i, slot in ipairs(order) do
        local t = C_Timer.NewTimer(0.8 * i, function()
            SendChatMessage(SLOT_CODES[slot], "SAY")
        end)
        table.insert(simTimers, t)
    end
end

------------------------------------------------------------
-- Mover
------------------------------------------------------------

function L:SetLocked(locked)
    local d = db()
    d.locked = not not locked
    EnsureDisplay()
    ApplyLockState()
    if not d.locked then
        L:Preview()
        print("|cff00ff00YippYapp|r: L'ura pentagon unlocked — drag to move, then press Lock.")
    end
end

------------------------------------------------------------
-- Public API
------------------------------------------------------------

function L:IsEnabled() return db().enabled end

function L:SetEnabled(on)
    db().enabled = not not on
    ApplyEnabled()
end

function L:Toggle() self:SetEnabled(not self:IsEnabled()) end

function L:IsRestrictedToEncounter() return db().restrictToEncounter end

function L:SetRestrictedToEncounter(on)
    db().restrictToEncounter = not not on
    -- Re-apply immediately so the listener state tracks the new mode.
    ApplyEnabled()
end

function L:Clear() CancelSim(); HideAll() end

function L:IsSayDebugOn()
    return chatFrame and chatFrame:IsEventRegistered("CHAT_MSG_SAY") or false
end

function L:GetMacroName(slot) return MACRO_PREFIX .. slot end

-- Reference rows for the UI: slot number, display label,
-- texture path (resolved locally), macro name.
function L:GetReference()
    local out = {}
    for i, code in ipairs(SLOT_CODES) do
        out[i] = {
            slot      = i,
            label     = SLOT_LABELS[i],
            code      = code,
            texture   = FullTexturePath(i),
            macroName = MACRO_PREFIX .. i,
        }
    end
    return out
end

------------------------------------------------------------
-- Slash dispatcher
------------------------------------------------------------

function L:HandleSlash(arg)
    local sub, rest = strsplit(" ", strtrim(arg or ""), 2)
    sub = strlower(sub or "")
    if sub == "" or sub == "preview" then
        self:Preview()
    elseif sub == "test" then
        self:Test()
    elseif sub == "clear" or sub == "hide" then
        self:Clear()
    elseif sub == "on" then
        self:SetEnabled(true)
        print("|cff00ff00YippYapp|r: L'ura helper |cff00ff00enabled|r")
    elseif sub == "off" then
        self:SetEnabled(false)
        print("|cff00ff00YippYapp|r: L'ura helper |cffff0000disabled|r")
    elseif sub == "macros" then
        self:CreateMacros()
    elseif sub == "unlock" then
        self:SetLocked(false)
    elseif sub == "lock" then
        self:SetLocked(true)
        self:Clear()
        print("|cff00ff00YippYapp|r: L'ura pentagon locked.")
    elseif sub == "say" then
        local state = strlower(strtrim(rest or ""))
        if state == "off" then self:SetSayDebug(false)
        else self:SetSayDebug(true) end
    elseif sub == "saytest" then
        self:SetSayDebug(true)
        self:SayTest()
    else
        print("|cff00ff00YippYapp|r: /yh lura [preview|test|clear|on|off|macros|unlock|lock|say on|off|saytest]")
    end
end

------------------------------------------------------------
-- Init
------------------------------------------------------------

local initFrame = CreateFrame("Frame")
initFrame:RegisterEvent("PLAYER_LOGIN")
initFrame:SetScript("OnEvent", function(self)
    self:UnregisterAllEvents()
    ApplyEnabled()
    -- Fresh install (or whenever the user left it unlocked): show the
    -- pentagon + Lock button so they can position it right away without
    -- having to open Raid Tools first.
    if db().enabled and not db().locked then
        L:Preview()
    end
end)
