-- TOG Tools — Smack (engine)
--
-- Smack-talk: fire a randomly-chosen line into chat when a combat
-- trigger hits. Modelled on the Gratz engine (FGI-Filters pattern) —
-- each FILTER names one trigger, an optional percent threshold, a pool
-- of lines, a target-channel set, and a per-filter cooldown in MINUTES.
--
-- Triggers (one per filter):
--   threshold (need `pct`, fire on a crossing):
--     self-hp-below     your health drops below pct%      (down-cross)
--     self-hp-above     your health recovers above pct%   (up-cross)
--     target-hp-below   target health drops below pct%    (re-arms on new target)
--     self-mana-below   your mana drops below pct%         (down-cross)
--     group-hp-below    a group member drops below pct%   (per-member)
--     self-bighit       a single hit ≥ pct% of your max health (event)
--   event (no `pct`):
--     killing-blow      you land a killing blow
--     combat-start      you enter combat (pull)
--     combat-end        combat ends and you're still alive
--     group-death       a group member dies
--     self-cc           you get feared / stunned / CC'd
--     self-death        you die (you can still chat as a ghost)
--     bloodlust         Bloodlust / Heroism / Time Warp / etc. lands on you
--
-- Combat-only: every trigger is gated on being in combat (combat-start
-- and self-death fire at their own boundary events). Unlike Gratz, sends
-- are IMMEDIATE — the whole point is to talk mid-fight — so there is no
-- combat queue.
--
-- IMPORTANT chat restriction (per Blizzard's ChatInfoDocumentation:
-- SendChatMessage HasRestrictions / RestrictedForMacroChatMessages):
-- SendChatMessage is NOT a protected function (no taint / blocked-action
-- error), but the chat types "observable by external players" — SAY, YELL,
-- EMOTE, and public CHANNEL — are SILENTLY dropped (FailureMode
-- "ReturnNothing") when sent from automated/addon code DURING AN INSTANCE
-- BOSS ENCOUNTER. So a "trash-talk the boss" line set to Say/Yell/Emote
-- will not appear during a dungeon/raid encounter; Party/Raid/Guild/
-- Officer/Instance are NOT restricted and work there. Open-world combat is
-- not an "instance encounter", so Say/Yell/Emote work normally there. We
-- still attempt the send (the no-op is harmless); the limitation is
-- surfaced in the tab help + channel tooltips rather than worked around.
--
-- Version safety: every trigger maps to an event present Era→Retail, and
-- triggers that can't apply degrade to silence — Bloodlust never lands on
-- Classic Era, mana filters no-op for a no-mana class/form. On Retail,
-- health/damage values can be `secret` in tainted combat; percent helpers
-- return nil for a secret value so a comparison never throws.

local _, addon = ...

local Smack = {}
addon.modules["smack"] = Smack

-- Mana power-type index. Enum.PowerType.Mana on modern clients; 0 is the
-- stable fallback for any flavour where Enum isn't populated.
local MANA = (Enum and Enum.PowerType and Enum.PowerType.Mana) or 0

-- issecretvalue exists only on Retail. Feature-detect so Classic doesn't
-- error referencing a missing global.
local _issecret = issecretvalue

-- COMBATLOG_OBJECT flag bits (stable constants; hardcoded so we don't
-- depend on the globals existing on every flavour). Used to recognise a
-- group member (party/raid affiliation, a player, not us) on UNIT_DIED.
local AFF_MINE   = 0x00000001
local AFF_PARTY  = 0x00000002
local AFF_RAID   = 0x00000004
local TYPE_PLAYER = 0x00000400
local band = bit and bit.band

-- Bloodlust family — any of these auras landing on the player fires the
-- bloodlust trigger. Empty result on Classic Era (none of these exist
-- there) so the trigger simply never fires.
local LUST_SPELLS = {
    [2825]   = true,  -- Bloodlust
    [32182]  = true,  -- Heroism
    [80353]  = true,  -- Time Warp
    [90355]  = true,  -- Ancient Hysteria
    [264667] = true,  -- Primal Rage (Hunter pet)
    [390386] = true,  -- Fury of the Aspects
    [466904] = true,  -- Bloodlust (Evoker / misc)
}

-- ---------------------------------------------------------------------------
-- Trigger classification
-- ---------------------------------------------------------------------------
-- Threshold triggers carry a `pct` and fire on a crossing (arm / re-arm).
local THRESHOLD_TRIGGER = {
    ["self-hp-below"]   = true,
    ["self-hp-above"]   = true,
    ["target-hp-below"] = true,
    ["self-mana-below"] = true,
    ["group-hp-below"]  = true,
}
-- Up-cross triggers fire when the value rises to/above pct; everything
-- else in THRESHOLD_TRIGGER is a down-cross (value drops below pct).
local ABOVE_TRIGGER = {
    ["self-hp-above"] = true,
}

-- Triggers that can't work on Retail/Midnight, for two confirmed reasons:
--
--  1. They read ANOTHER unit's current health, which is a secret value to
--     addons there (UnitHealth is documented SecretReturns=true; only the
--     player's OWN health reads back plainly — DBM reads player health ungated
--     but guards every other unit): target-hp-below, group-hp-below.
--
--  2. They depend on COMBAT_LOG_EVENT_UNFILTERED, whose registration is BLOCKED
--     for addons on Midnight 12.0 — verified in-game here (issecure() is false
--     in our PLAYER_ENTERING_WORLD handler, so the registration is never even
--     attempted; the diagnostic "PEW wire CLEU" line never prints) and
--     independently by Recount (which couldn't work around it and pivoted to
--     C_DamageMeter) and by DBM's stub Midnight mods: killing-blow, self-bighit,
--     group-death, bloodlust.
--
-- All of these stay fully functional on Classic (no secret system, CLEU works).
-- The gate lives in eachActiveFilter so a Classic-made filter synced to a Retail
-- client via the account-wide SV just stays dormant — no fire, no notice —
-- instead of confusing the user. Retail keeps every self/event trigger
-- (self-hp, self-mana, combat start/end, CC, death) plus the Manual hotkey.
local CLASSIC_ONLY_TRIGGER = {
    ["target-hp-below"] = true,
    ["group-hp-below"]  = true,
    ["killing-blow"]    = true,
    ["self-bighit"]     = true,
    ["group-death"]     = true,
    ["bloodlust"]       = true,
}

-- The secret system + combat-log block are Midnight 12.0+ ("addongeddon").
-- Gate on isRetail120Plus, NOT isRetail: War Within 11.x had no secrets and a
-- working combat log, and the Mainline TOC still lists 110207 — so an 11.x
-- client keeps these triggers.
local function isTriggerAvailable(triggerKey)
    if CLASSIC_ONLY_TRIGGER[triggerKey]
        and addon.gameVersion and addon.gameVersion.isRetail120Plus then
        return false
    end
    return true
end

-- Exposed for GUI/SmackTab.lua so the trigger dropdown only offers what works
-- on this flavour.
function Smack:IsTriggerAvailable(triggerKey)
    return isTriggerAvailable(triggerKey)
end

-- ---------------------------------------------------------------------------
-- DB helpers
-- ---------------------------------------------------------------------------
local function SmackDB()
    return addon.DB and addon.DB.global and addon.DB.global.smack
end

local function isModuleEnabled()
    return addon:IsModuleEnabled("smack")
end

-- A name/string headed for an outgoing message. On Retail, UnitName and the
-- combat-log destName can be SECRET values in tainted combat; `tostring` on a
-- secret throws (the same error ChatThrottleLib hits in its SendChatMessage
-- post-hook), so any secret/nil collapses to "". Every placeholder value that
-- reaches a chat line goes through here. Classic has no secret system, so
-- this is just `v or ""` there.
local function safeStr(v)
    if v == nil then return "" end
    if _issecret and _issecret(v) then return "" end
    return v
end

local function safeName(unit)
    return safeStr(UnitName(unit))
end

local function playerName()
    return safeStr(UnitName("player"))
end

-- ---------------------------------------------------------------------------
-- Percent helpers — nil on a secret value (Retail tainted combat) or a
-- zero/absent max so callers skip rather than throw or divide-by-zero.
-- ---------------------------------------------------------------------------
local function pct(cur, max)
    if cur == nil or max == nil then return nil end
    if _issecret and (_issecret(cur) or _issecret(max)) then return nil end
    if max <= 0 then return nil end
    return math.floor((cur / max) * 100 + 0.5)
end

local function hpPct(unit)
    return pct(UnitHealth(unit), UnitHealthMax(unit))
end

local function manaPct(unit)
    return pct(UnitPower(unit, MANA), UnitPowerMax(unit, MANA))
end

-- ---------------------------------------------------------------------------
-- Combat / arm / cooldown state (in-memory; not persisted)
-- ---------------------------------------------------------------------------
local _inCombat = false
-- _armed[filterName] = is this filter ready to fire? A threshold filter
-- disarms on fire and re-arms when the value crosses back to the waiting
-- side. Treated as armed unless explicitly false.
local _armed = {}
-- _armedGroup[filterName][guid] = same, per group member (group-hp-below).
local _armedGroup = {}
-- Per-filter last-fire wall-clock for the minute cooldown.
local _cooldownLast = {}
-- Which combat-log-driven triggers have an active filter — refreshed at
-- combat start so the high-frequency CLEU handler can bail before parsing.
local _clActive = {}
-- Filters we've already warned the user can't read their value (secret) —
-- one notice per filter per session so a non-firing health filter isn't a
-- silent mystery, without spamming on every health tick.
local _secretNotified = {}
-- Hotkey state (declared here so the OnEvent handler can read the pending
-- flag; the binding functions live further down). See the Hotkeys section.
local _hotkeyOwner        = CreateFrame("Frame", "TOGToolsSmackHotkeyOwner")
local _hotkeyButtons      = {}      -- pool of global-named click targets
local _hotkeyThrottle     = {}      -- [filterName] = last manual-fire time (1s floor)
local _hotkeyApplyPending = false   -- a bind was requested in combat; redo on REGEN_ENABLED
local _cleuSeen           = false   -- diagnostic: have we received ANY combat-log event?
local _publicBlockedNotified = {}   -- [filterName] = warned once that auto public chat is blocked

-- Sorted by name, not pairs(). Finding tog-AV, the twin of tog-AS in Gratz: each
-- matching filter sends its OWN line, so hash order decided which of two filters on
-- one trigger spoke first and the pair could swap between identical events -- while
-- Smack:GetFilters and the tab both sort by name. A total order, because db.filters
-- is keyed by name.
local function eachActiveFilter(trigger, fn)
    if not isModuleEnabled() then return end
    local db = SmackDB()
    if not db or not db.filters then return end
    local matched = {}
    for _, f in pairs(db.filters) do
        if f.enabled and (trigger == nil or f.trigger == trigger)
            and isTriggerAvailable(f.trigger) then
            matched[#matched + 1] = f
        end
    end
    table.sort(matched, function(a, b) return (a.name or "") < (b.name or "") end)
    for _, f in ipairs(matched) do
        fn(f)
    end
end

-- ---------------------------------------------------------------------------
-- Channel dispatch — immediate (no combat queue). Group/guild channels are
-- skipped silently when not connected to them.
-- ---------------------------------------------------------------------------
local CHANNEL_MAP = {
    guild    = "GUILD",
    officer  = "OFFICER",
    party    = "PARTY",
    raid     = "RAID",
    say      = "SAY",
    yell     = "YELL",
    instance = "INSTANCE_CHAT",
    emote    = "EMOTE",
}
Smack.CHANNEL_ORDER = { "guild", "officer", "party", "raid", "say", "yell", "instance", "emote" }

-- Channels "observable by external players". On Retail/Midnight an AUTOMATED
-- (non-hardware) SendChatMessage to these is blocked — ADDON_ACTION_BLOCKED via
-- C_ChatInfo.SendChatMessage, confirmed in-game even in the open world. A
-- keypress is a hardware event and is allowed, so we only skip them for
-- auto-fires on 12.0+; a hotkey send (isHardware) goes through to every channel.
local PUBLIC_CHANNEL = { say = true, yell = true, emote = true }

--- Returns two values: whether a public channel was SKIPPED (so the caller can notify
--- once), and whether anything was actually SENT.
---
--- Membership per channel comes from addon.Chat.ChannelAvailable (Compat.lua). This
--- function used to carry its own copy of Gratz's three-arm dispatch, bare
--- `IsInGroup()` and all, so finding tog-AQ was live here too -- RAID true in any
--- five-man, and INSTANCE_CHAT suppressed in the instance group it exists for. That is
--- finding tog-AT, and the predicate moved to Compat rather than being fixed twice.
---
--- The public-channel block stays HERE and is deliberately not part of the shared
--- predicate: it is not a membership question, and it turns on whether this particular
--- send is carrying a hardware event -- something only the caller knows.
local function sendToChannels(f, msg, isHardware)
    if not f.channels or not msg or msg == "" then return false, false end
    local blockPublic = (not isHardware)
        and addon.gameVersion and addon.gameVersion.isRetail120Plus
    local skippedPublic = false
    local sent = false
    for _, key in ipairs(Smack.CHANNEL_ORDER) do
        if f.channels[key] then
            if PUBLIC_CHANNEL[key] and blockPublic then
                skippedPublic = true   -- automated public chat is blocked here
            else
                local ct = CHANNEL_MAP[key]
                if ct and addon.Chat.ChannelAvailable(ct) then
                    SendChatMessage(msg, ct)
                    sent = true
                end
            end
        end
    end
    return skippedPublic, sent
end

-- ---------------------------------------------------------------------------
-- Line selection + placeholder expansion
-- ---------------------------------------------------------------------------
-- Placeholders: [me] [target] [hp] [targethp] [member]. Unresolved → "".
local function expandLine(template, ctx)
    if not template or template == "" then return "" end
    ctx = ctx or {}
    local out = template
    out = out:gsub("%[me%]",       ctx.me       or playerName())
    out = out:gsub("%[member%]",   ctx.member   or "")
    out = out:gsub("%[targethp%]", ctx.targethp and tostring(ctx.targethp) or "")
    out = out:gsub("%[target%]",   ctx.target   or "")
    out = out:gsub("%[hp%]",       ctx.hp and tostring(ctx.hp) or "")
    return out
end

local function pickLine(f)
    local lines = f.lines
    if not lines or #lines == 0 then return nil end
    -- Skip blank lines so a stray empty row in the editor never sends "".
    local pool = {}
    for _, l in ipairs(lines) do
        if l and l ~= "" then pool[#pool + 1] = l end
    end
    if #pool == 0 then return nil end
    return pool[math.random(#pool)]
end

-- ---------------------------------------------------------------------------
-- Fire — the single choke point: cooldown gate, pick + expand a line,
-- dispatch, bump count.
-- ---------------------------------------------------------------------------
-- isHardware: true for a manual hotkey press (a deliberate hardware event).
-- It does double duty: (1) skip the per-filter minute cooldown (the hotkey
-- keeps its own 1s anti-double-tap floor), and (2) allow public channels
-- (Say/Yell/Emote), which are blocked for AUTOMATED sends on Retail/Midnight.
local function fireFilter(f, ctx, isHardware)
    if not isHardware then
        local cd = (f.cooldown or 1) * 60   -- minutes → seconds
        if cd > 0 then
            local last = _cooldownLast[f.name]
            if last and (GetTime() - last) < cd then return end
        end
    end
    local template = pickLine(f)
    if not template then return end
    local msg = expandLine(template, ctx)
    if not msg or msg == "" then return end
    -- Final safety net: never hand a secret value to SendChatMessage. The ctx
    -- placeholders are already sanitised via safeStr/safeName, so msg should
    -- be plain — but if a secret ever slips through, ChatThrottleLib's
    -- SendChatMessage post-hook would tostring() it and throw. Skip instead.
    if _issecret and _issecret(msg) then return end
    local skippedPublic, sent = sendToChannels(f, msg, isHardware)
    -- Auto-fire wanted a public channel that Retail blocks: tell the user once
    -- (instead of triggering ADDON_ACTION_BLOCKED) how to get it working.
    if skippedPublic and not _publicBlockedNotified[f.name] then
        _publicBlockedNotified[f.name] = true
        if addon.lib and addon.lib.Print then
            addon.lib:Print(string.format(
                "|cffff8800Smack:|r \"|cffffd700%s|r\" targets |cffffd700Say/Yell/Emote|r, but Retail/Midnight blocks |cffffd700automatic|r public chat. Use |cffffd700Party / Raid / Guild|r for auto-triggers, or bind a |cffffd700hotkey|r (a keypress is allowed in any channel).",
                tostring(f.name)))
        end
    end
    -- Only a line that reached a channel counts as a fire. Finding tog-AU, the twin of
    -- tog-AR in Gratz. A filter whose every ticked channel is unavailable -- guild-only
    -- while guildless, or Say/Yell/Emote on an auto-fire under Retail's public-chat
    -- block -- said nothing, yet still bumped the tab's Count column and burned the
    -- per-filter cooldown, which here is MINUTES. Returning before both leaves the
    -- filter ready to speak the moment it actually can.
    if not sent then
        addon:Debug(string.format("[smack] '%s' reached no channel; not counted",
            tostring(f.name)))
        return false
    end
    _cooldownLast[f.name] = GetTime()
    f.count = (f.count or 0) + 1
    addon:Debug(string.format("[smack] fired '%s' (%s)%s: %s",
        tostring(f.name), tostring(f.trigger),
        isHardware and " [hotkey]" or "", msg))
    return true
end

-- ---------------------------------------------------------------------------
-- Threshold evaluation (crossing + re-arm)
-- ---------------------------------------------------------------------------
local function conditionMet(f, value)
    if ABOVE_TRIGGER[f.trigger] then
        return value >= (f.pct or 0)
    end
    return value < (f.pct or 0)
end

local function evalThreshold(f, value, ctx)
    if value == nil then
        -- Value unreadable: on Retail/Midnight a unit's health is a SECRET
        -- value to addons (UnitHealth is documented SecretReturns=true), so it
        -- can't be compared — and there's no read-around (a secret may be
        -- displayed, never compared/sorted; confirmed against Recount's
        -- Tracker_Midnight). Warn ONCE per filter so a non-firing health filter
        -- isn't a silent mystery; on Classic (no secret system) this never hits.
        if not _secretNotified[f.name] then
            _secretNotified[f.name] = true
            if addon.lib and addon.lib.Print then
                addon.lib:Print(string.format(
                    "|cffff8800Smack:|r \"|cffffd700%s|r\" can't fire on this game version \226\128\148 it reads a unit's health, which the game hides from addons here (your own-health, mana, and event triggers still work).",
                    tostring(f.name)))
            end
        end
        return
    end
    if conditionMet(f, value) then
        if _armed[f.name] ~= false then
            -- fireFilter returns FALSE only for "reached no channel" (tog-AU); every
            -- other bail -- cooldown, no line, empty message -- returns nil. So the
            -- crossing is consumed exactly as before in those cases, and is NOT
            -- consumed when the filter was structurally unable to speak. Disarming
            -- there would need the value to cross back and re-cross before it could
            -- ever be heard.
            if fireFilter(f, ctx) ~= false then
                _armed[f.name] = false
            end
        end
    else
        _armed[f.name] = true   -- re-armed: value back on the waiting side
    end
end

local function evalThresholdGroup(f, unit, value, ctx)
    if value == nil then return end
    local guid = UnitGUID and UnitGUID(unit)
    if not guid then return end
    _armedGroup[f.name] = _armedGroup[f.name] or {}
    local armed = _armedGroup[f.name]
    if value < (f.pct or 0) then
        if armed[guid] ~= false then
            if fireFilter(f, ctx) ~= false then   -- see evalThreshold (tog-AU)
                armed[guid] = false
            end
        end
    else
        armed[guid] = true
    end
end

-- Single-unit current value for the trigger, used to arm correctly at
-- combat start (so an "above" filter doesn't fire just because you pulled
-- at full health). Group filters return nil → default-armed.
local function currentValueForFilter(f)
    if f.trigger == "self-hp-below" or f.trigger == "self-hp-above" then
        return hpPct("player")
    elseif f.trigger == "self-mana-below" then
        return manaPct("player")
    elseif f.trigger == "target-hp-below" then
        if UnitExists and UnitExists("target") then return hpPct("target") end
    end
    return nil
end

-- Refreshed at combat start (filters rarely change mid-fight). Lets the
-- high-frequency UNIT_HEALTH / UNIT_POWER / CLEU handlers bail before doing
-- any per-filter work when no filter of that trigger is active.
local function armFiltersForCombat()
    wipe(_armed)
    wipe(_armedGroup)
    wipe(_clActive)
    local active = {}
    eachActiveFilter(nil, function(f)
        _clActive[f.trigger] = true
        active[#active + 1] = f.trigger
        if THRESHOLD_TRIGGER[f.trigger] then
            local v = currentValueForFilter(f)
            _armed[f.name] = (v == nil) or (not conditionMet(f, v))
        end
    end)
    addon:Debug("[smack] combat start; active triggers: " ..
        (#active > 0 and table.concat(active, ", ") or "(none)"))
end

-- ---------------------------------------------------------------------------
-- Event wiring
-- ---------------------------------------------------------------------------
-- All of the events below are INSECURE — registering them is allowed even
-- though TOGTools' load chain is tainted (the !!TOGT / Diagnostics
-- seterrorhandler wrap runs earlier in the load order). The one SECURE-
-- registration event, COMBAT_LOG_EVENT_UNFILTERED, is deliberately NOT
-- registered here: registering it from tainted code trips
-- ADDON_ACTION_FORBIDDEN (that was the bug — only the secure event was
-- blocked; these insecure ones registered fine).
--
-- Instead it's added from the PLAYER_ENTERING_WORLD handler, gated on
-- issecure() — the documented test for whether the running path is
-- untainted, so we only attempt the secure call when it's guaranteed not to
-- be blocked. PEW is Blizzard-dispatched (a clean stack) and fires on login,
-- /reload, and every zone change, so on the off chance one dispatch is
-- tainted by another addon we simply retry on the next. Using the actual
-- security check beats assuming a C_Timer callback happens to be clean.
local _issecure  = issecure       -- feature-detect (present on all live flavours)
local _clewWired = false          -- COMBAT_LOG_EVENT_UNFILTERED registered yet?

local _evtFrame = CreateFrame("Frame")
_evtFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
_evtFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
_evtFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
_evtFrame:RegisterEvent("PLAYER_DEAD")
_evtFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
_evtFrame:RegisterEvent("UNIT_HEALTH")
_evtFrame:RegisterEvent("UNIT_POWER_UPDATE")
-- Loss-of-control is the purpose-built CC signal; pcall-register in case a
-- flavour lacks it (degrades to "self-cc never fires"). Also insecure.
pcall(function() _evtFrame:RegisterEvent("LOSS_OF_CONTROL_ADDED") end)

local function selfCtx(hp)
    return {
        me     = playerName(),
        hp     = hp,
        target = (UnitExists and UnitExists("target") and safeName("target")) or "",
    }
end

local function handleCombatLog()
    if not _inCombat then return end
    if not (_clActive["self-bighit"] or _clActive["killing-blow"]
        or _clActive["group-death"] or _clActive["bloodlust"]) then
        return
    end
    local info = { CombatLogGetCurrentEventInfo() }
    local subevent  = info[2]
    local sourceGUID = info[4]
    local destGUID   = info[8]
    local destName   = info[9]
    local destFlags  = info[10]
    local selfGUID   = UnitGUID and UnitGUID("player")

    -- Diagnostic: prove whether CLEU reaches us at all on this client (once),
    -- and surface the relevant subevents. If "CLEU is firing" never appears,
    -- COMBAT_LOG_EVENT_UNFILTERED isn't delivering to the addon on Midnight.
    if not _cleuSeen then
        _cleuSeen = true
        addon:Debug("[smack] CLEU is firing (first subevent=" .. tostring(subevent) .. ")")
    end

    -- Whether a GUID is comparable (not secret). On Midnight CLEU GUIDs can be
    -- secret; comparing one throws, so guard every GUID compare below.
    local function guidEq(a, b)
        if not a or not b then return false end
        if _issecret and (_issecret(a) or _issecret(b)) then return false end
        return a == b
    end

    -- Killing blow: you landed it (PARTY_KILL is logged for the killer).
    if subevent == "PARTY_KILL" and _clActive["killing-blow"] then
        addon:Debug("[smack] CLEU PARTY_KILL seen; matched-self=" ..
            tostring(guidEq(sourceGUID, selfGUID)))
        if guidEq(sourceGUID, selfGUID) then
            local ctx = { me = playerName(), target = safeStr(destName) }
            eachActiveFilter("killing-blow", function(f) fireFilter(f, ctx) end)
        end
        return
    end

    -- Group member died (party/raid player, not us).
    if subevent == "UNIT_DIED" and _clActive["group-death"] and band and destFlags then
        local isGroupPlayer = band(destFlags, AFF_PARTY + AFF_RAID) ~= 0
            and band(destFlags, AFF_MINE) == 0
            and band(destFlags, TYPE_PLAYER) ~= 0
        if isGroupPlayer then
            local ctx = { me = playerName(), member = safeStr(destName) }
            eachActiveFilter("group-death", function(f) fireFilter(f, ctx) end)
        end
        return
    end

    -- Below here we care only about events targeting the player.
    if not guidEq(destGUID, selfGUID) then return end

    -- Bloodlust family applied to you.
    if _clActive["bloodlust"]
        and (subevent == "SPELL_AURA_APPLIED" or subevent == "SPELL_AURA_REFRESH") then
        local spellId = info[12]
        if spellId and LUST_SPELLS[spellId] then
            local ctx = selfCtx(hpPct("player"))
            eachActiveFilter("bloodlust", function(f) fireFilter(f, ctx) end)
        end
        return
    end

    -- Big hit taken: a single hit ≥ pct% of your max health.
    if _clActive["self-bighit"] then
        local amount
        if subevent == "SWING_DAMAGE" then
            amount = info[12]
        elseif subevent == "SPELL_DAMAGE" or subevent == "SPELL_PERIODIC_DAMAGE"
            or subevent == "RANGE_DAMAGE" then
            amount = info[15]
        end
        if amount then
            local maxHP = UnitHealthMax("player")
            if not (_issecret and (_issecret(amount) or _issecret(maxHP))) then
                if maxHP and maxHP > 0 then
                    local hitPct = (amount / maxHP) * 100
                    local ctx = selfCtx(hpPct("player"))
                    eachActiveFilter("self-bighit", function(f)
                        if hitPct >= (f.pct or 0) then fireFilter(f, ctx) end
                    end)
                end
            end
        end
    end
end

_evtFrame:SetScript("OnEvent", function(_, event, arg1, arg2)
    if event == "PLAYER_ENTERING_WORLD" then
        -- Add the secure combat-log event only when the running path is
        -- untainted (issecure()), so the registration can't be blocked.
        -- Once wired, drop PEW — we don't need a per-zone handler.
        if not _clewWired and (not _issecure or _issecure()) then
            _evtFrame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
            _clewWired = true
            -- Diagnostic: IsEventRegistered returns false if the registration
            -- was silently blocked (ADDON_ACTION_FORBIDDEN doesn't throw). This
            -- tells us whether CLEU is even hooked up on this client.
            addon:Debug("[smack] PEW wire CLEU: issecure=" ..
                tostring((not _issecure) or _issecure()) .. " registered=" ..
                tostring(_evtFrame:IsEventRegistered("COMBAT_LOG_EVENT_UNFILTERED")))
            -- Reloading mid-combat: PLAYER_REGEN_DISABLED already fired, so
            -- pick up the in-combat state now instead of staying dormant
            -- until the next fight (matters when testing with /reload).
            if InCombatLockdown and InCombatLockdown() then
                _inCombat = true
                armFiltersForCombat()
            end
        end
        if _clewWired then _evtFrame:UnregisterEvent("PLAYER_ENTERING_WORLD") end
        return

    elseif event == "PLAYER_REGEN_DISABLED" then
        _inCombat = true
        armFiltersForCombat()
        local ctx = selfCtx(hpPct("player"))
        eachActiveFilter("combat-start", function(f) fireFilter(f, ctx) end)

    elseif event == "PLAYER_REGEN_ENABLED" then
        -- Fire combat-end while still flagged in-combat so the gate inside
        -- fireFilter's callers is satisfied, but only if we survived.
        if not (UnitIsDeadOrGhost and UnitIsDeadOrGhost("player")) then
            local ctx = selfCtx(hpPct("player"))
            eachActiveFilter("combat-end", function(f) fireFilter(f, ctx) end)
        end
        _inCombat = false
        wipe(_armed)
        wipe(_armedGroup)
        wipe(_clActive)
        -- A hotkey (re)bind requested during combat was deferred; do it now
        -- that binding changes are allowed again.
        if _hotkeyApplyPending then Smack:ApplyHotkeys() end

    elseif event == "PLAYER_DEAD" then
        -- Death is its own boundary — allowed regardless of the combat flag.
        local ctx = { me = playerName(), target = (UnitExists and UnitExists("target") and safeName("target")) or "" }
        eachActiveFilter("self-death", function(f) fireFilter(f, ctx) end)

    elseif event == "PLAYER_TARGET_CHANGED" then
        if not _inCombat then return end
        -- New target: re-arm target filters against the new unit's health
        -- WITHOUT firing — targeting an already-low mob isn't a "drop". If the
        -- target is currently above the line we arm so a later drop fires;
        -- if it's already below we leave it disarmed.
        eachActiveFilter("target-hp-below", function(f)
            local v = currentValueForFilter(f)
            _armed[f.name] = (v == nil) or (not conditionMet(f, v))
        end)

    elseif event == "UNIT_HEALTH" then
        if not _inCombat then return end
        local unit = arg1
        if unit == "player" then
            if not (_clActive["self-hp-below"] or _clActive["self-hp-above"]) then return end
            local v = hpPct("player")
            if v == nil then return end
            eachActiveFilter("self-hp-below", function(f) evalThreshold(f, v, selfCtx(v)) end)
            eachActiveFilter("self-hp-above", function(f) evalThreshold(f, v, selfCtx(v)) end)
        elseif unit == "target" then
            if not _clActive["target-hp-below"] then return end
            local v = hpPct("target")
            eachActiveFilter("target-hp-below", function(f)
                evalThreshold(f, v, { me = playerName(), target = safeName("target"), targethp = v })
            end)
        elseif unit and (unit:match("^party%d") or unit:match("^raid%d")) then
            if not _clActive["group-hp-below"] then return end
            local v = hpPct(unit)
            eachActiveFilter("group-hp-below", function(f)
                evalThresholdGroup(f, unit, v, { me = playerName(), member = safeName(unit), hp = v })
            end)
        end

    elseif event == "UNIT_POWER_UPDATE" then
        if not _inCombat then return end
        if arg1 == "player" and arg2 == "MANA" and _clActive["self-mana-below"] then
            local v = manaPct("player")
            if v == nil then return end
            eachActiveFilter("self-mana-below", function(f) evalThreshold(f, v, selfCtx(hpPct("player"))) end)
        end

    elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
        handleCombatLog()

    elseif event == "LOSS_OF_CONTROL_ADDED" then
        if not _inCombat then return end
        local ctx = selfCtx(hpPct("player"))
        eachActiveFilter("self-cc", function(f) fireFilter(f, ctx) end)
    end
end)

-- ---------------------------------------------------------------------------
-- Hotkeys — a filter may carry a key combo that fires it ON DEMAND. A keypress
-- is a HARDWARE EVENT, so the resulting SendChatMessage is user-initiated and
-- sidesteps both the secret system and the encounter public-chat block — hotkey
-- smack works everywhere, every flavour, even /say mid-boss.
--
-- We bind via SetOverrideBindingClick on a private owner frame: those bindings
-- are reversible (ClearOverrideBindings restores the original; they auto-clear
-- on logout), so we NEVER rewrite the user's saved keybinds or clobber other
-- addons permanently. Conflicts are surfaced to the user in the tab (they pick
-- the key), not silently stolen. Binding changes are protected in combat, so we
-- defer an apply made in combat to PLAYER_REGEN_ENABLED.
-- (State — _hotkeyOwner / _hotkeyButtons / _hotkeyThrottle / _hotkeyApplyPending
-- — is declared up top with the other module state so the event handler can
-- read the pending flag.)
-- ---------------------------------------------------------------------------

-- Fire a filter from its hotkey. Skips the minute cooldown (deliberate press)
-- but keeps a 1s anti-double-tap floor.
function Smack:FireHotkey(name)
    if not isModuleEnabled() then return end
    local db = SmackDB()
    local f = db and db.filters and db.filters[name]
    if not f or not f.enabled then return end
    local now = GetTime()
    if _hotkeyThrottle[name] and (now - _hotkeyThrottle[name]) < 1 then return end
    _hotkeyThrottle[name] = now
    fireFilter(f, selfCtx(hpPct("player")), true)
end

local function getHotkeyButton(i)
    local btn = _hotkeyButtons[i]
    if btn then return btn end
    btn = CreateFrame("Button", "TOGToolsSmackHotkeyBtn" .. i, _hotkeyOwner)
    btn:RegisterForClicks("AnyDown")
    btn:SetScript("OnClick", function(self)
        if self.filterName then Smack:FireHotkey(self.filterName) end
    end)
    _hotkeyButtons[i] = btn
    return btn
end

-- (Re)bind every filter's hotkey. Cheap; called on login and after any filter
-- save/delete. ClearOverrideBindings drops only OUR previous overrides.
function Smack:ApplyHotkeys()
    if InCombatLockdown and InCombatLockdown() then
        _hotkeyApplyPending = true   -- binding changes are blocked in combat
        return
    end
    _hotkeyApplyPending = false
    if ClearOverrideBindings then ClearOverrideBindings(_hotkeyOwner) end
    -- A disabled module must own no bindings. The clear above is the whole fix; returning before
    -- the install loop is what makes it stick. Firing was already gated (see the check in the
    -- hotkey handler), so the harm this removes is not the module speaking -- it is a disabled
    -- module continuing to SWALLOW a keypress that the user has rebound to something else.
    if not isModuleEnabled() then return end
    if not SetOverrideBindingClick then return end
    local db = SmackDB()
    if not db or not db.filters then return end
    local i = 0
    -- Sorted for the same reason as eachActiveFilter, plus one specific to bindings:
    -- if TWO filters claim the SAME key, SetOverrideBindingClick is last-writer-wins,
    -- so under pairs() WHICH filter that key fired varied between sessions with no
    -- change by the user. Sorted, the later name wins consistently -- still a conflict,
    -- but a reproducible one the user can see and fix (tog-AV).
    for _, f in ipairs(self:GetFilters()) do
        if f.hotkey and f.hotkey ~= "" then
            i = i + 1
            local btn = getHotkeyButton(i)
            btn.filterName = f.name
            SetOverrideBindingClick(_hotkeyOwner, false, f.hotkey, btn:GetName(), "LeftButton")
        end
    end
    -- Clear stale names on unused pooled buttons (defensive; their bindings are
    -- already gone after ClearOverrideBindings).
    for j = i + 1, #_hotkeyButtons do _hotkeyButtons[j].filterName = nil end
end

-- Called by GUI/Settings.lua the moment the module toggle moves, in BOTH directions. This is the
-- edge the flag alone does not provide: on disable it removes our override bindings, on enable it
-- puts them back. Without it, disabling left every hotkey bound to a module that would no longer
-- act on it, and re-enabling did not restore them until some unrelated filter save happened to
-- call ApplyHotkeys.
function Smack:OnModuleToggled()
    self:ApplyHotkeys()
end

-- ---------------------------------------------------------------------------
-- Public API consumed by GUI/SmackTab.lua
-- ---------------------------------------------------------------------------
function Smack:GetFilters()
    local db = SmackDB()
    if not db or not db.filters then return {} end
    local list = {}
    for _, f in pairs(db.filters) do tinsert(list, f) end
    table.sort(list, function(a, b) return (a.name or "") < (b.name or "") end)
    return list
end

function Smack:GetFilter(name)
    local db = SmackDB()
    return db and db.filters and db.filters[name] or nil
end

function Smack:SaveFilter(filter)
    local db = SmackDB()
    if not db then return end
    db.filters = db.filters or {}
    db.filters[filter.name] = filter
    self:ApplyHotkeys()   -- (re)bind in case the filter's hotkey changed
end

function Smack:DeleteFilter(name)
    local db = SmackDB()
    if not db or not db.filters then return end
    db.filters[name] = nil
    self:ApplyHotkeys()   -- drop its binding
end

-- Default-shape factory for a fresh filter. The tab uses this for "new".
function Smack:NewFilterDefaults()
    return {
        name     = "",
        enabled  = true,
        trigger  = "self-hp-below",
        pct      = 35,
        lines    = {},
        channels = { say = true },
        cooldown = 1,   -- minutes
        hotkey   = "",  -- key combo for on-demand firing (optional)
        count    = 0,
    }
end

-- Engine entry for the per-module toggle framework. Events are wired at file
-- load (insecure ones) + the PLAYER_ENTERING_WORLD handler (the secure combat-
-- log event) — see the _evtFrame comment above. Here we bind the filter
-- hotkeys: login is out of combat and the DB is ready, so the override bindings
-- take effect immediately.
function Smack:OnEnable()
    self:ApplyHotkeys()
end
