-- TOG Tools — Name Prefix module (engine)
-- Prepends a configurable nickname prefix to outgoing chat messages.
--
-- Hook strategy:
--   Retail 12.0+  : EventRegistry "ChatFrame.OnEditBoxPreSendText".
--   Classic/older : per-editbox HookScript("OnKeyDown", ...) that fires
--                   on Enter / NumpadEnter and applies the prefix BEFORE
--                   the editbox's untouched OnEnterPressed handler runs.
--
-- Why every other entry point was rejected:
--   - `SendChatMessage` (global wrap)              : on BCC/Anniversary
--     1.15.x the chat editbox mixin captures it as a localized upvalue;
--     reassigning the global never fires for user-typed chat. Verified
--     empirically — the wrap installed but ModifyMessage never entered.
--   - `ChatEdit_SendText` (global wrap)            : same problem on
--     Anniversary. ChatFrameEditBoxMixin:OnEnterPressed dispatches via
--     `self:SendText()` (mixin method), not the global function.
--     Verified empirically too — install printed but no fire on /say.
--   - `ChatFrameEditBoxMixin.SendText` (method replace) : breaks OPie.
--     OPie's Rewire dispatches macrotext ring slots via
--     `securecall(box.SendText, box, false)` using synthetic editboxes
--     that share the mixin. Patching the mixin method breaks OPie macros.
--   - SetScript on OnEnterPressed WITHOUT securecall around origScript :
--     the editbox handler runs as addon Lua; calling origScript directly
--     keeps the taint flag set for the entire downstream chain. ParseText
--     dispatches /logout from inside the tainted chain and the protected
--     Logout() trips ADDON_ACTION_FORBIDDEN.
--   - SetScript on OnEnterPressed WITH securecall around origScript     :
--     v0.3.3 shipped this. securecall clears taint at the call boundary,
--     but on TBC / Anniversary 1.15.x the slash-dispatch chain
--     (ParseText -> slash-handler -> Logout()) still trips
--     ADDON_ACTION_FORBIDDEN for /camp /exit /logout — confirmed in the
--     field after v0.3.3 shipped. The replaced OnEnterPressed script slot
--     itself becomes addon-owned, and the taint check at the C level
--     blocks Logout() despite the securecall boundary.
--
-- HookScript("OnKeyDown", ...) avoids the problem entirely:
--   * OnKeyDown fires in its own C dispatch frame, BEFORE OnEnterPressed
--     is dispatched as a separate C-level call. Our hook returns to C
--     before OnEnterPressed begins, so addon Lua is never on the call
--     stack when SendText / ParseText / Logout() run.
--   * HookScript chains alongside any existing OnKeyDown handler instead
--     of claiming the script slot. Chat editboxes have no default
--     OnKeyDown binding, so our hook is the only one — and the
--     OnEnterPressed slot stays as the original Blizzard handler, so the
--     entire slash-dispatch chain executes in a fully secure context.
--   * For slash commands ApplyPrefix early-exits on the leading `/`, so
--     the editbox text is never touched anyway.
--
-- Per-editbox HookScript only touches user-visible ChatFrameN editboxes;
-- OPie's synthetic Rewire editboxes share the mixin via metatable but
-- have no instance-level OnKeyDown handler, so they are unaffected.

local _, addon = ...
local gv = addon.gameVersion

local NamePrefix = {}
addon.NamePrefix = NamePrefix

-- ---------------------------------------------------------------------------
-- Internal state
-- ---------------------------------------------------------------------------
local _hooked = false   -- set true once the hook is installed

-- ---------------------------------------------------------------------------
-- Core: apply the configured prefix to msg for chatType / channelArg.
-- Returns the (possibly prefixed) message, or msg unchanged if no prefix
-- should be applied.  Pure — no side effects.
-- ---------------------------------------------------------------------------
local function ApplyPrefix(msg, chatType, channelArg)
    local cfg = addon.DB and addon.DB.global.namePrefix
    if not (cfg and cfg.enabled) then return msg end

    local nick = cfg.nickname
    if not nick or nick == "" then return msg end

    -- Optionally suppress when nickname matches current character name
    if cfg.hideIfCharName then
        local charName = UnitName("player")
        if charName and nick == charName then return msg end
    end

    if not msg or msg == "" then return msg end

    -- Slash commands and macros are never prefixed
    if string.sub(msg, 1, 1) == "/" then return msg end

    -- Skip messages starting with "!" (addon commands like !keys)
    if cfg.skipExclamation and string.sub(msg, 1, 1) == "!" then return msg end

    local shouldPrefix = false
    if     chatType == "SAY"           and cfg.say      then shouldPrefix = true
    elseif chatType == "GUILD"         and cfg.guild    then shouldPrefix = true
    elseif chatType == "OFFICER"       and cfg.officer  then shouldPrefix = true
    elseif chatType == "PARTY"         and cfg.party    then shouldPrefix = true
    elseif chatType == "PARTY_LEADER"  and cfg.party    then shouldPrefix = true
    elseif chatType == "RAID"          and cfg.raid     then shouldPrefix = true
    elseif chatType == "RAID_WARNING"  and cfg.raid     then shouldPrefix = true
    elseif chatType == "INSTANCE_CHAT" and cfg.instance then shouldPrefix = true
    elseif chatType == "CHANNEL"       and cfg.channel and cfg.channel ~= "" then
        if channelArg then
            local _, chname = GetChannelName(channelArg)
            if chname and strupper(cfg.channel) == strupper(chname) then
                shouldPrefix = true
            end
        end
    end

    if not shouldPrefix then return msg end

    -- Build prefix from format string (safe substitution)
    local fmt = (cfg.format and cfg.format ~= "") and cfg.format or "(%s): "
    local prefix = string.format(fmt, nick)

    -- Skip if msg already begins with the prefix. ElvUI's chat history
    -- recall (Up arrow) re-populates the editbox with a previously-sent
    -- message that already contains the prefix; re-prepending would
    -- produce "(Vish): (Vish): hi".
    if string.sub(msg, 1, #prefix) == prefix then return msg end

    return prefix .. msg
end

-- Read editBox text, apply prefix in-place via SetText. Used by both the
-- Retail EventRegistry path and the Classic OnKeyDown hook.
-- The InCombatLockdown guard is kept because SetText on the editbox can
-- taint the SendChatMessage call that follows on some Retail builds.
local function ModifyMessage(editBox)
    if InCombatLockdown() then return end
    local msg = editBox:GetText()
    if not msg or msg == "" then return end
    local chatType = editBox:GetAttribute("chatType") or editBox.chatType or "SAY"
    local channelTarget = editBox:GetAttribute("channelTarget") or editBox.channelTarget
    local result = ApplyPrefix(msg, chatType, channelTarget)
    if result ~= msg then
        editBox:SetText(result)
    end
    addon:Debug(string.format("NamePrefix fire: chatType=%s prefixed=%s",
        tostring(chatType), tostring(result ~= msg)))
end

-- ---------------------------------------------------------------------------
-- Receive-side GRM main-name de-duplication (opt-in; OFF by default)
-- ---------------------------------------------------------------------------
-- When a guildmate's addon self-prefixes their main "(Bob)" into a message AND
-- you run GRM with "show main name" active, GRM ALSO injects "(Bob): " into the
-- SAME message body — so you see the main twice. With the toggle on, a receive-
-- side chat filter collapses the redundant copy.
--
-- Containment: this is a GRM-tied convenience and is ENTIRELY INERT unless the
-- toggle is ticked — no chat filter is registered and no GRM call is made, so
-- there is zero integration and zero cost when it's off.
--
-- Safety: we only remove a "(main)" token that appears a SECOND time near the
-- front (GRM's tag + the sender's self-prefix), and only when it matches the
-- sender's GRM main. A lone "(main)", or "(main)" deeper in real text, is never
-- touched — so we can't eat a legitimately-typed "(brb) ...".
local _issecretvalue = issecretvalue   -- Retail secret-value guard; nil on Classic

-- Channels GRM decorates with main tags (mirrors GRM_G.mainTagEvents).
local DEDUP_EVENTS = {
    "CHAT_MSG_GUILD", "CHAT_MSG_OFFICER", "CHAT_MSG_WHISPER",
    "CHAT_MSG_PARTY", "CHAT_MSG_PARTY_LEADER",
    "CHAT_MSG_RAID", "CHAT_MSG_RAID_LEADER",
    "CHAT_MSG_INSTANCE_CHAT", "CHAT_MSG_INSTANCE_CHAT_LEADER",
}

-- The sender's GRM main (lowercased, realm-stripped) IF GRM is present AND
-- actively decorating this sender's messages — otherwise nil (we do nothing).
-- Consumes GRM's own functions, per our data-scope rule (never our own roster).
local function grmSenderMain(sender)
    local GRM = _G.GRM
    if not (GRM and GRM.S and GRM.GetPlayer and GRM.PlayerIsAnAlt and GRM.GetFormattedMainName) then
        return nil
    end
    local s = GRM.S()
    if not (s and s.showMainName) then return nil end       -- GRM isn't adding tags
    local player = GRM.GetPlayer(sender)
    if not (player and GRM.PlayerIsAnAlt(player)) then return nil end
    local main = GRM.GetFormattedMainName(sender, false)
    if not main or main == "" then return nil end
    main = main:match("^([^%-]+)") or main                  -- strip realm
    return main:lower()
end

-- Remove the redundant SECOND "(main)" (the sender's self-prefix) near the
-- front, keeping GRM's (which is first because GRM prepends). Unchanged when
-- there's nothing to collapse.
local function collapseMain(msg, mainLower)
    local lower = msg:lower()
    local tok   = "(" .. mainLower .. ")"
    local first = lower:find(tok, 1, true)
    if not first then return msg end
    local second = lower:find(tok, first + #tok, true)
    if not second or second > 80 then return msg end        -- one copy, or deep in text
    local afterTok = second + #tok
    local sep      = msg:sub(afterTok):match("^%s*:?%s*") or ""   -- eat one "(): " / " " separator
    local head     = msg:sub(1, second - 1):gsub("%s+$", " ")
    return head .. msg:sub(afterTok + #sep)
end

local function dedupFilter(_, _, msg, sender, ...)
    local cfg = addon.DB and addon.DB.global and addon.DB.global.namePrefix
    if not (cfg and cfg.dedupeGRMMain) then return false end
    if _issecretvalue and _issecretvalue(msg) then return false end
    if not sender or sender == "" then return false end
    local mainLower = grmSenderMain(sender)
    if not mainLower then return false end
    local newmsg = collapseMain(msg, mainLower)
    if newmsg ~= msg then return false, newmsg, sender, ... end
    return false
end

local _dedupOn = false
local _dedupPollTries = 0
local DEDUP_POLL_MAX = 20   -- ~20s of 1s polls: covers a slow roster scan without leaking a timer forever

-- True once GRM's AddMainToChat filter is actually in the chat-filter chain.
-- GRM flips this global at the exact moment it registers that filter
-- (GRM_Core: MessageHookControl), so it's a precise "GRM is decorating now"
-- signal — consistent with grmSenderMain already reaching into GRM internals.
local function grmChatHookReady()
    local G = _G.GRM_G
    return (G and G.MainHookConfigured) and true or false
end

-- Ordering is the whole game: our collapse filter only sees a doubled "(Main)"
-- if it runs AFTER GRM's AddMainToChat in the filter chain, and filters run in
-- registration order. GRM registers on a variable schedule (login LoadAddon +
-- post-roster-scan, gated on IsInGuild), so the old fixed 3s delay raced it and
-- sometimes registered us FIRST — then we saw one "(Main)", did nothing, and GRM
-- appended its copy afterwards. We now poll GRM_G.MainHookConfigured and register
-- only once GRM is hooked, guaranteeing we're later in the chain. If GRM isn't
-- installed there's nothing to dedupe, so we no-op. Registration is idempotent.
function NamePrefix:ApplyDedupSetting()
    local cfg = addon.DB and addon.DB.global and addon.DB.global.namePrefix
    local on  = (cfg and cfg.dedupeGRMMain) and true or false
    if on == _dedupOn then return end

    if on and not grmChatHookReady() then
        -- GRM present but not hooked yet: retry so we land AFTER it. Bounded so
        -- a GRM install where the player never enters a guild can't loop forever.
        if _G.GRM and C_Timer and _dedupPollTries < DEDUP_POLL_MAX then
            _dedupPollTries = _dedupPollTries + 1
            C_Timer.After(1, function() self:ApplyDedupSetting() end)
        end
        return
    end
    _dedupPollTries = 0

    _dedupOn = on
    for _, ev in ipairs(DEDUP_EVENTS) do
        if on then
            ChatFrame_AddMessageEventFilter(ev, dedupFilter)
        else
            ChatFrame_RemoveMessageEventFilter(ev, dedupFilter)
        end
    end
end

-- ---------------------------------------------------------------------------
-- Hook installation
-- ---------------------------------------------------------------------------
function NamePrefix:Install()
    if _hooked then return end

    if gv.isRetail120Plus and EventRegistry then
        -- Retail 12.0+: this event fires BEFORE SendText reads the editbox,
        -- so our SetText reaches the message that gets sent.
        EventRegistry:RegisterCallback(
            "ChatFrame.OnEditBoxPreSendText",
            function(_, editBox)
                local ok, err = pcall(ModifyMessage, editBox)
                if not ok and addon.DB and addon.DB.global.namePrefix.enabled then
                    print("|cffFF8000TOGTools|r NamePrefix error: " .. tostring(err))
                end
            end,
            NamePrefix
        )
        _hooked = true
        addon:Debug("NamePrefix: hook installed (EventRegistry)")
    else
        -- Classic / older Retail: per-editbox HookScript("OnKeyDown", ...)
        -- on each ChatFrameN.EditBox. On Enter / NumpadEnter we apply the
        -- prefix to the editbox text; the editbox's untouched OnEnterPressed
        -- handler then fires as a separate C-level dispatch and reads the
        -- modified text. Because OnEnterPressed runs in its own C frame
        -- with no addon Lua on the stack, the slash-dispatch chain
        -- (ParseText -> slash-handler -> Logout()) is fully secure, so
        -- /camp / /exit / /logout no longer trip ADDON_ACTION_FORBIDDEN.
        local wrapped = 0
        for i = 1, NUM_CHAT_WINDOWS or 10 do
            local eb = _G["ChatFrame" .. i .. "EditBox"]
            if eb and eb.HookScript then
                eb:HookScript("OnKeyDown", function(editBox, key)
                    if key == "ENTER" or key == "NUMPADENTER" then
                        pcall(ModifyMessage, editBox)
                    end
                end)
                wrapped = wrapped + 1
            end
        end
        _hooked = true
        addon:Debug(string.format(
            "NamePrefix: hook installed (OnKeyDown x%d)", wrapped))
    end
end

function NamePrefix:OnEnable()
    self:Install()
    -- Kick off the opt-in receive-side dedup. ApplyDedupSetting polls for GRM's
    -- chat hook and only registers once GRM is in the chain (so we run after it),
    -- so the exact start moment doesn't matter — a small defer just avoids
    -- polling from the very first frame. No-ops when the toggle is off or GRM is
    -- absent, so there's no cost in the default case.
    if C_Timer then
        C_Timer.After(3, function() self:ApplyDedupSetting() end)
    else
        self:ApplyDedupSetting()
    end
end

-- ---------------------------------------------------------------------------
-- Register as a module (engine only — no tab key; tab is in NamePrefixTab.lua)
-- ---------------------------------------------------------------------------
-- The tab registration lives in GUI/NamePrefixTab.lua which loads after this
-- file and merges its Draw/WINDOW_SIZE into addon.modules["nameprefix"].
addon.modules["nameprefix"] = NamePrefix
