-- 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

-- Audit round 4 finding G. The finding named Mailbox / LoginDigest / IdHitThat;
-- NamePrefix is a fourth site with the same inline `cfg.enabled` spelling, found
-- by reading every `.enabled` in the tree rather than trusting that list. Same
-- reasoning: the inline form defaults a MISSING flag OFF, addon:IsModuleEnabled
-- defaults it ON, and the default belongs in one place.
--
-- ⚠ This deliberately does NOT touch dedupFilter, which tests cfg.dedupeGRMMain
-- and never the module flag. SETTLED by the user on 2026-08-16: the two stay
-- independent. The reasoning is at dedupFilter itself, where anyone tempted to
-- add the gate will actually be looking.
local function isEnabled()
    return addon:IsModuleEnabled("nameprefix")
end

-- ---------------------------------------------------------------------------
-- 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)
    -- The `addon.DB.global` hop is guarded here the same way it is at dedupFilter
    -- and ApplyDedupSetting. It used to read `addon.DB and addon.DB.global.namePrefix`
    -- -- three reads of one path in one file, spelled two ways (audit finding tog-AG).
    local cfg = addon.DB and addon.DB.global and addon.DB.global.namePrefix
    if not cfg or not isEnabled() 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 the user's format string. This comment used to claim
    -- "safe substitution" and the call was bare (audit finding tog-AF), which it
    -- is not: `cfg.format` is free text from the settings panel, and string.format
    -- RAISES on plenty of things a person types -- a literal percent ("100% "),
    -- a numeric spec ("%d" against a string), or a second placeholder with no
    -- second argument ("%s %s").
    --
    -- The tab's live preview already flags those in red (GUI/NamePrefixTab.lua:41),
    -- but it stores the value anyway, so a bad format reaches this line. Falling
    -- back to the DEFAULT rather than to no prefix at all is deliberate: an
    -- unexpected-but-visible "(Nick): " is self-correcting, because the user sees
    -- it in chat immediately, whereas the prefix silently never appearing again is
    -- indistinguishable from the module being broken. The default is a literal
    -- rather than `fmt` so the fallback cannot raise in turn.
    local fmt = (cfg.format and cfg.format ~= "") and cfg.format or "(%s): "
    local ok, prefix = pcall(string.format, fmt, nick)
    if not ok then prefix = "(" .. nick .. "): " end

    -- 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

-- Exposed for the offline test harness. ApplyPrefix and collapseMain are the
-- module's only real logic — everything else is editbox and chat-filter
-- plumbing that needs a live client. Keeping them as file-locals too means the
-- hot paths above still resolve without a table lookup.
NamePrefix.ApplyPrefix   = ApplyPrefix
NamePrefix.CollapseMain  = collapseMain
NamePrefix.GRMSenderMain = grmSenderMain

-- ⛔ DELIBERATELY NOT GATED ON THE MODULE TOGGLE. Settled by the user on
-- 2026-08-16, after audit round 4 raised it as an open question -- do not "fix"
-- this into a gate on the strength of consistency with the other modules.
--
-- The reasoning, which is the part that decides it: NamePrefix has two halves
-- that share one settings panel but not one job.
--   * ApplyPrefix is the SEND side -- it adds YOUR nickname to messages YOU send,
--     and it is gated on the module toggle.
--   * this is the RECEIVE side -- when Guild Roster Manager is installed it
--     prepends "(Main)" to incoming guild chat, and if the SENDER also runs this
--     addon their line arrives already tagged, so you read "(Bob) (Bob): hello".
--     collapseMain strips the second copy.
--
-- The doubled tag is produced by OTHER PLAYERS' addons. Turning your own module
-- off stops you tagging your outgoing messages; it does nothing about what your
-- guildies send or what GRM prepends. So gating this on the module toggle would
-- mean "switch off my nickname -> everyone else's chat gets uglier", which is not
-- what that toggle promises. The dedupe has its own checkbox for turning off.
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
-- True while a poll chain is live. PLAYER_GUILD_UPDATE can fire repeatedly, and without this each
-- fire would start a SECOND chain sharing one counter -- reviewer finding rev-G's remedy stacking
-- on itself. Set when a retry is scheduled, cleared when that retry runs.
local _dedupPolling = false
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.
-- `isRetry` is set only by this function's own scheduled callback. It is what makes the
-- budget belong to a CHAIN rather than to a call.
--
-- THE COUNTER IS SET IN ONE PLACE -- where a chain BEGINS -- and reset nowhere, which is
-- reviewer finding rev-I's remedy and is the whole point. It used to be reset at each of
-- the ways a chain could END, first one place, then two, and rev-I found the third: the
-- `on == _dedupOn` early return, taken when the user unticks the box mid-chain. A player
-- who ticks, waits fifteen of the twenty seconds, and unticks left the counter stranded
-- at 15, so the next chain got five polls. Chasing a fourth exit path would have been the
-- same mistake again; there is no exit path to chase now, because nothing on the way out
-- touches it.
function NamePrefix:ApplyDedupSetting(isRetry)
    if isRetry then
        -- This chain has no outstanding timer at this instant. Cleared here rather than in
        -- the callback so that a chain is "polling" for its whole life, which is what the
        -- PLAYER_GUILD_UPDATE watcher tests to avoid stacking a second chain on a live one.
        _dedupPolling = false
    else
        _dedupPollTries = 0
    end

    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
            _dedupPolling   = true
            C_Timer.After(1, function() self:ApplyDedupSetting(true) end)
        end
        return
    end

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

-- Both hook paths call ModifyMessage inside a pcall, and they used to disagree about
-- what happens next: the Retail path printed, the Classic path discarded silently.
--
-- Reviewer finding rev-H. The reason I recorded for leaving that alone was wrong twice.
-- It is NOT a change to "every flavour" -- Retail already reports, so making Classic
-- report removes a divergence rather than introducing one. And the pcall's scope is not
-- the format-string bug that prompted it: it wraps the whole of ModifyMessage, which
-- reads the DB, calls GetChannelName and strupper, and reads and writes the editbox. So
-- the addon's PRIMARY flavour was keeping a permanent silent-failure channel on its chat
-- send path, and the flavour that reported is the one fewest of these users are on.
--
-- Reported ONCE per session rather than per keypress, which is why this is a function
-- and not a copied line: the Classic hook runs on every Enter, so an unlatched print
-- would be a message per sent line, and the existing Retail print has that same problem
-- on any repeating failure. One latch, both paths, strictly quieter than Retail is today
-- and strictly louder than Classic was.
local _sendErrorReported = false
local function reportSendError(err)
    if _sendErrorReported or not isEnabled() then return end
    _sendErrorReported = true
    print("|cffFF8000TOGTools|r NamePrefix error while adding your prefix: " .. tostring(err)
        .. " (reported once per session; your message was still sent, unprefixed)")
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 then reportSendError(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
                        -- Reports through the same latch as the Retail path above
                        -- (reviewer finding rev-H). This used to discard silently.
                        local ok, err = pcall(ModifyMessage, editBox)
                        if not ok then reportSendError(err) end
                    end
                end)
                wrapped = wrapped + 1
            end
        end
        _hooked = true
        addon:Debug(string.format(
            "NamePrefix: hook installed (OnKeyDown x%d)", wrapped))
    end
end

-- Restart the GRM poll when the thing it is waiting on can have changed.
--
-- Reviewer finding rev-G: resetting the poll budget fixed the RE-TICK path and left the
-- likelier one alone -- the player who ticks the box while guildless, burns the twenty
-- polls in the first twenty seconds, joins a guild an hour later, and never re-ticks
-- because the checkbox still reads "on" and nothing reported anything. GRM only hooks
-- chat once it has a guild, so joining one is exactly the moment the answer changes, and
-- before this the only thing in the addon that could start another chain was the user
-- clicking the box again. That is why it presented as "sometimes it works" and was
-- cleared by a relog.
--
-- Idempotent by ApplyDedupSetting's own `on == _dedupOn` early return once the filters
-- are registered, and `_dedupPolling` stops a second chain stacking on a live one --
-- PLAYER_GUILD_UPDATE fires more than once per join.
local _guildFrame
local function watchGuildChanges(self)
    if _guildFrame or not CreateFrame then return end
    _guildFrame = CreateFrame("Frame")
    _guildFrame:RegisterEvent("PLAYER_GUILD_UPDATE")
    _guildFrame:SetScript("OnEvent", function()
        if _dedupOn or _dedupPolling then return end
        self:ApplyDedupSetting()
    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
    watchGuildChanges(self)
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
