local ADDON_NAME, RPAL = ...

--[[
ODYN Chat QoL
=============
A clean-room chat composition layer for RP-oriented chat. This module does not
contain or derive code/assets from Emote Splitter or any other third-party
splitter. It uses Blizzard's public chat edit-box/API surface and Total RP 3's
public runtime data when available.
]]

RPAL.ChatQoL = RPAL.ChatQoL or {}
local ChatQoL = RPAL.ChatQoL

local SAFE_MESSAGE_BYTES = 245
local COMPOSE_LIMIT = 8192
local EDITBOX_MAX_BYTES = 16384 -- room for WoW invisible formatting/link bytes

local function trim(value)
    return tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")
end

local function upper(value)
    return tostring(value or ""):upper()
end

local function utf8SafeEnd(text, index)
    local n = #text
    index = math.max(0, math.min(tonumber(index) or 0, n))
    if index >= n then return n end
    -- If the next byte is a UTF-8 continuation byte, the proposed end lands
    -- inside a code point. Move left until the next byte starts a character.
    while index > 0 do
        local nextByte = text:byte(index + 1)
        if not nextByte or nextByte < 0x80 or nextByte > 0xBF then break end
        index = index - 1
    end
    return index
end

local function advancePastSpace(text, index)
    local n = #text
    while index <= n do
        local b = text:byte(index)
        if b == 32 or b == 9 or b == 10 or b == 13 then
            index = index + 1
        else
            break
        end
    end
    return index
end

local function copyStack(stack)
    local out = {}
    for i = 1, #stack do out[i] = stack[i] end
    return out
end

local function updateDelimiterStack(stack, ch, previousByte)
    if ch ~= '"' and ch ~= '*' then return end
    if previousByte == 92 then return end -- backslash-escaped delimiter

    if stack[#stack] == ch then
        table.remove(stack)
        return
    end

    -- Prefer proper nesting. If malformed text closes a delimiter underneath a
    -- different open delimiter, close that matching state rather than allowing
    -- synthetic markers to grow forever across every later chunk.
    for i = #stack - 1, 1, -1 do
        if stack[i] == ch then
            table.remove(stack, i)
            return
        end
    end

    stack[#stack + 1] = ch
end

local function scanState(text, firstIndex, lastIndex, initialStack)
    local stack = copyStack(initialStack or {})
    local previousByte
    for i = firstIndex, lastIndex do
        local b = text:byte(i)
        if b == 34 then
            updateDelimiterStack(stack, '"', previousByte)
        elseif b == 42 then
            updateDelimiterStack(stack, '*', previousByte)
        end
        previousByte = b
    end
    return stack
end

local function openerFor(stack)
    return table.concat(stack, "")
end

local function closerFor(stack)
    local out = {}
    for i = #stack, 1, -1 do out[#out + 1] = stack[i] end
    return table.concat(out, "")
end

local function findNaturalBreak(text, startIndex, hardEnd)
    if hardEnd <= startIndex then return hardEnd, false end

    local span = hardEnd - startIndex + 1
    local softFloor = math.max(startIndex, hardEnd - math.floor(span * 0.42))
    local bestSpace
    local bestPunctuation

    -- Prefer the latest word boundary, not the latest sentence-ending dot.
    -- The outgoing continuation marker should be the visible reason the line
    -- ended; original punctuation stays with its own sentence in a later part.
    for i = hardEnd, softFloor, -1 do
        local b = text:byte(i)
        if not bestSpace and (b == 32 or b == 9) then
            bestSpace = i - 1
            break
        elseif not bestPunctuation and (b == 46 or b == 33 or b == 63 or b == 44 or b == 59 or b == 58) then
            bestPunctuation = i
        end
    end

    local chosen = bestSpace or bestPunctuation
    if chosen and chosen >= startIndex then return utf8SafeEnd(text, chosen), false end
    return utf8SafeEnd(text, hardEnd), true
end

-- Splits only the content. Synthetic quote/star closers and reopeners are
-- inserted into the transmitted chunks so each WoW chat line is independently
-- well-formed while the original composition remains conceptually continuous.
function ChatQoL.SplitText(text, maxBytes)
    text = tostring(text or "")
    maxBytes = math.max(32, math.floor(tonumber(maxBytes) or SAFE_MESSAGE_BYTES))
    if #text <= maxBytes then return { text } end

    local chunks = {}
    local pos = 1
    local n = #text
    local state = {}
    local guard = 0

    while pos <= n and guard < 512 do
        guard = guard + 1
        local prefix = openerFor(state)
        local reserveForClosers = 4 -- quote + star, with a little breathing room
        local room = maxBytes - #prefix - reserveForClosers
        if room < 16 then room = 16 end

        local hardEnd = utf8SafeEnd(text, math.min(n, pos + room - 1))
        if hardEnd < pos then hardEnd = utf8SafeEnd(text, math.min(n, pos + room)) end
        if hardEnd < pos then hardEnd = pos end

        local cut, forced = hardEnd, false
        if hardEnd < n then cut, forced = findNaturalBreak(text, pos, hardEnd) end
        if cut < pos then cut = hardEnd end

        local endState = scanState(text, pos, cut, state)
        local suffix = (cut < n) and closerFor(endState) or ""
        local segment = text:sub(pos, cut):gsub("%s+$", "")

        -- A very long unbroken token can still force a hard cut. Mark that
        -- transition with a hyphen when there is space and the text did not
        -- already supply one. This is strictly a display aid, not stored text.
        local nextPos = advancePastSpace(text, cut + 1)
        local midWord = forced and cut < n
            and text:sub(cut, cut):match("[%w']")
            and text:sub(nextPos, nextPos):match("[%w']")

        -- Every non-final part gets an explicit continuation hyphen so a split
        -- RP line can never look as though it naturally ended at a full stop.
        -- At a normal word boundary use the familiar " -" marker; inside an
        -- open quote/action (or a genuinely forced mid-word cut), keep the
        -- hyphen attached before the synthetic closing delimiter:
        --   I draw my blade -
        --   "I will not-"
        -- This marker exists only in transmitted chunks, never in authored text.
        local continued = cut < n
        local hyphen = ""
        if continued and segment:sub(-1) ~= "-" then
            hyphen = (midWord or #endState > 0) and "-" or " -"
        end

        local output = prefix .. segment .. hyphen .. suffix
        while #output > maxBytes and cut > pos do
            cut = utf8SafeEnd(text, cut - 1)
            endState = scanState(text, pos, cut, state)
            suffix = (cut < n) and closerFor(endState) or ""
            segment = text:sub(pos, cut):gsub("%s+$", "")
            nextPos = advancePastSpace(text, cut + 1)
            midWord = cut < n
                and text:sub(cut, cut):match("[%w']")
                and text:sub(nextPos, nextPos):match("[%w']")
            continued = cut < n
            hyphen = ""
            if continued and segment:sub(-1) ~= "-" then
                hyphen = (midWord or #endState > 0) and "-" or " -"
            end
            output = prefix .. segment .. hyphen .. suffix
        end

        chunks[#chunks + 1] = output
        state = endState
        pos = advancePastSpace(text, cut + 1)
    end

    if #chunks == 0 then chunks[1] = text:sub(1, maxBytes) end
    return chunks
end

function ChatQoL.ParseSpecialCommand(raw)
    raw = tostring(raw or "")
    local command, body = raw:match("^%s*/([Cc][SsEe])%s+(.+)$")
    if command then
        local mode = command:sub(2, 2):lower() == "e" and "EMOTE" or "SAY"
        return { kind = "COMPANION", mode = mode, text = trim(body) }
    end

    command, body = raw:match("^%s*/([Nn][SsEe])%s+(.+)$")
    if command then
        local name, text = body:match("^%s*(.-)%s*:%s*(.+)$")
        if not name or trim(name) == "" or not text or trim(text) == "" then
            return { kind = "NPC_ERROR", command = "/" .. command:lower() }
        end
        local mode = command:sub(2, 2):lower() == "e" and "EMOTE" or "SAY"
        return { kind = "NPC", mode = mode, name = trim(name), text = trim(text) }
    end

    return nil
end

function ChatQoL.ParseLeadingChannel(raw, fallbackType)
    raw = tostring(raw or "")

    -- Whisper slash commands need their recipient preserved separately from
    -- the authored body. ODYN owns the Enter press while a long message is
    -- queued, so every split chunk must retain the original tell target.
    local whisperCommand, whisperTarget, whisperBody = raw:match("^%s*/([%a]+)%s+([^%s]+)%s+(.+)$")
    if whisperCommand then
        whisperCommand = whisperCommand:lower()
        if whisperCommand == "w" or whisperCommand == "whisper" or whisperCommand == "t" or whisperCommand == "tell" then
            return "WHISPER", trim(whisperBody), true, trim(whisperTarget)
        end
    end

    local command, body = raw:match("^%s*/([%a]+)%s+(.+)$")
    if command then
        command = command:lower()
        if command == "e" or command == "em" or command == "emote" then
            return "EMOTE", trim(body), true
        elseif command == "s" or command == "say" then
            return "SAY", trim(body), true
        elseif command == "y" or command == "yell" then
            return "YELL", trim(body), true
        end
    end
    return upper(fallbackType or "SAY"), raw, false
end

function ChatQoL.BuildTRPPayload(prefix, sayPattern, speakerName, mode, text, bracketCompanion)
    prefix = tostring(prefix or "|| ")
    sayPattern = trim(sayPattern or "says:")
    speakerName = trim(speakerName)
    text = tostring(text or "")
    local label = bracketCompanion and ("[" .. speakerName .. "]") or speakerName
    if upper(mode) == "SAY" then
        return prefix .. label .. " " .. sayPattern .. " " .. text
    end
    return prefix .. label .. " " .. text
end

local eventFrame = CreateFrame("Frame")
local originalChatEditSendText
local sendWrapper
local installEnterHandler
local editBoxState = setmetatable({}, { __mode = "k" })
local popup
local queue
local autoGeneration = 0

ChatQoL.speaker = ChatQoL.speaker or "SELF"
ChatQoL.companionKey = ChatQoL.companionKey or nil
ChatQoL.oneShotNPC = nil

local function getSettings()
    if RPAL.GetSettings then
        local ok, settings = pcall(RPAL.GetSettings)
        if ok and type(settings) == "table" then return settings end
    end
    return (RPAL.db and RPAL.db.settings) or {}
end

local function isEnabled()
    local settings = getSettings()
    return settings.chatQoLEnabled ~= false
end

local function splitterEnabled()
    local settings = getSettings()
    return settings.chatSplitterEnabled ~= false
end

local function printODYN(message, errorColor)
    local color = errorColor and "|cffff7777" or "|cff66e6ff"
    print(color .. "ODYN:|r " .. tostring(message or ""))
end

local function getTRP()
    local api = rawget(_G, "TRP3_API")
    if type(api) ~= "table" then return nil end
    return api
end

local function getTRPPrefix()
    local api = getTRP()
    if api and api.chat and type(api.chat.configNPCTalkPrefix) == "function" then
        local ok, value = pcall(api.chat.configNPCTalkPrefix)
        if ok and type(value) == "string" and value ~= "" then return value end
    end
    return "|| "
end

local function getTRPSayPattern()
    local api = getTRP()
    local loc = api and api.loc
    local value = loc and loc.NPC_TALK_SAY_PATTERN
    if type(value) == "string" and value ~= "" then return value end
    return "says:"
end

local function getPlayerRPName()
    local api = getTRP()
    if api and api.profile and type(api.profile.getData) == "function" then
        local ok, data = pcall(api.profile.getData, "player/characteristics")
        if ok and type(data) == "table" then
            local first = trim(data.FN)
            local last = trim(data.LN)
            if first ~= "" then return last ~= "" and (first .. " " .. last) or first end
        end
    end
    return UnitName("player") or "Self"
end

local function isAccessibleValue(value)
    if value == nil then return false end
    local checker = rawget(_G, "canaccessvalue")
    if type(checker) == "function" then
        local ok, accessible = pcall(checker, value)
        if ok and accessible == false then return false end
    end
    return true
end

local function normalizeCompanionProfile(profile, source, entityID, fallbackName, fallbackIcon, profileID)
    if type(profile) ~= "table" then return nil end
    -- Current TRP companion profiles expose their characteristics through
    -- profile.data. Keep a direct-table fallback for older/public API shapes.
    local data = type(profile.data) == "table" and profile.data or profile
    if type(data) ~= "table" then return nil end

    local name = ""
    if isAccessibleValue(data.NA) then name = trim(data.NA) end
    if name == "" and isAccessibleValue(fallbackName) then name = trim(fallbackName) end
    if name == "" then return nil end

    source = upper(source or "COMPANION")
    local identity = isAccessibleValue(entityID) and tostring(entityID) or name
    local key = source .. ":" .. identity
    return {
        name = name,
        icon = data.IC or fallbackIcon,
        color = data.NH,
        raw = profile,
        source = source,
        entityID = identity,
        key = key,
        profileID = profileID,
    }
end

local function getActiveCompanions()
    local api = getTRP()
    local player = api and api.companions and api.companions.player
    if type(player) ~= "table" then return {} end

    local util = rawget(_G, "TRP3_CompanionUtil")
    local out, seen = {}, {}

    -- TRP has exposed these functions both as closure-style public API methods
    -- and, across older builds, as table methods. Accept either calling
    -- convention so ODYN does not silently fail on a perfectly valid profile.
    local function callPlayer(methodName, ...)
        local method = player[methodName]
        if type(method) ~= "function" then return false end
        local ok, a, b, c, d = pcall(method, ...)
        if ok then return true, a, b, c, d end
        ok, a, b, c, d = pcall(method, player, ...)
        if ok then return true, a, b, c, d end
        return false
    end

    local function callUtil(methodName, ...)
        if type(util) ~= "table" or type(util[methodName]) ~= "function" then return false end
        local method = util[methodName]
        local ok, a, b, c, d = pcall(method, ...)
        if ok then return true, a, b, c, d end
        ok, a, b, c, d = pcall(method, util, ...)
        if ok then return true, a, b, c, d end
        return false
    end

    local profiles
    do
        local ok, value = callPlayer("getProfiles")
        if ok and type(value) == "table" then profiles = value end
    end

    local function getProfileByEntity(entityID)
        if not isAccessibleValue(entityID) then return nil end
        local ok, profile = callPlayer("getCompanionProfile", tostring(entityID))
        if ok and type(profile) == "table" then return profile end
        return nil
    end

    local function getProfileID(entityID)
        if not isAccessibleValue(entityID) then return nil end
        local ok, profileID = callPlayer("getCompanionProfileID", tostring(entityID))
        if ok and isAccessibleValue(profileID) then return profileID end
        return nil
    end

    local function profileFromQueryLine(queryLine)
        if not isAccessibleValue(queryLine) then return nil end
        queryLine = tostring(queryLine or "")
        if queryLine == "" then return nil end

        -- TRP query lines append _<profileID> only when that currently summoned
        -- entity is linked to a companion profile. Match against actual profile
        -- IDs rather than splitting on underscores in a pet's displayed name.
        if profiles then
            for profileID, profile in pairs(profiles) do
                local suffix = "_" .. tostring(profileID)
                if #queryLine > #suffix and queryLine:sub(-#suffix) == suffix then
                    return profile, tostring(profileID), queryLine:sub(1, #queryLine - #suffix)
                end
            end
        end

        local profile = getProfileByEntity(queryLine)
        if profile then return profile, getProfileID(queryLine), queryLine end
        return nil
    end

    local function add(profile, source, entityID, fallbackName, fallbackIcon, profileID)
        local resolved = normalizeCompanionProfile(profile, source, entityID, fallbackName, fallbackIcon, profileID)
        if not resolved or seen[resolved.key] then return end
        seen[resolved.key] = true
        out[#out + 1] = resolved
    end

    local function addByEntity(source, entityID, fallbackName, fallbackIcon)
        if not isAccessibleValue(entityID) then return false end
        entityID = tostring(entityID)
        local profile = getProfileByEntity(entityID)
        if not profile then return false end
        add(profile, source, entityID, fallbackName or entityID, fallbackIcon, getProfileID(entityID))
        return true
    end

    local function addFromQuery(methodName, source, fallbackIcon)
        local ok, queryLine = callPlayer(methodName)
        if not ok or not isAccessibleValue(queryLine) or tostring(queryLine or "") == "" then return false end
        local profile, profileID, entityID = profileFromQueryLine(queryLine)
        if profile then
            add(profile, source, entityID, entityID, fallbackIcon, profileID)
            return true
        end
        return false
    end

    -- Regular combat/class pet. TRP links these profiles by the summoned pet's
    -- displayed name, which is also what the [Pet Name] NPC-talk marker uses.
    if UnitExists and UnitExists("pet") then
        local petName
        if UnitNameUnmodified then
            local ok, value = pcall(UnitNameUnmodified, "pet")
            if ok and isAccessibleValue(value) then petName = value end
        end
        if (not petName or petName == "") and UnitName then
            local ok, value = pcall(UnitName, "pet")
            if ok and isAccessibleValue(value) then petName = value end
        end
        if petName and petName ~= "" then addByEntity("PET", petName, petName, nil) end
    end

    -- Ask TRP's own active query helpers as well. These cover battle pets and
    -- the Beast Mastery secondary pet and are deliberately additive: a player
    -- can have more than one linked companion active at the same time.
    addFromQuery("getCurrentPetQueryLine", "PET")
    addFromQuery("getCurrentBattlePetQueryLine", "BATTLE_PET")
    addFromQuery("getCurrentSecondaryPetQueryLine", "SECONDARY_PET")
    addFromQuery("getCurrentMountQueryLine", "MOUNT")

    -- Vanity/battle pet direct fallback. TRP's CompanionUtil gives us the same
    -- displayed name it uses for companion association/tooltips.
    do
        local okPet, petID = callUtil("GetSummonedCompanionPetID")
        if okPet and petID then
            local okInfo, info = callUtil("GetCompanionPetInfo", petID)
            if okInfo and type(info) == "table" and info.name then
                addByEntity("BATTLE_PET", info.name, info.name, info.icon)
            end
        end
    end

    -- Mounts are associated by spell ID in TRP, not by the display name. Prefer
    -- TRP's current-mount profile helper, then fall back to the active mount's
    -- spell ID and association lookup. This can coexist with an active pet.
    do
        local okProfile, mountProfile, mountProfileID = callPlayer("getCurrentMountProfile")
        local okSpell, mountSpellID = callPlayer("getCurrentMountSpellID")

        local mountID, mountInfo
        local okMount, valueMount = callUtil("GetSummonedMountID")
        if okMount and valueMount then
            mountID = valueMount
            local okInfo, valueInfo = callUtil("GetMountInfo", mountID)
            if okInfo and type(valueInfo) == "table" then mountInfo = valueInfo end
        end

        -- If the public helper did not expose spellID, Blizzard's mount journal
        -- does. GetMountInfoByID returns name, spellID and icon among its first
        -- values on modern clients.
        if not mountSpellID and mountID and C_MountJournal and type(C_MountJournal.GetMountInfoByID) == "function" then
            local ok, name, spellID, icon = pcall(C_MountJournal.GetMountInfoByID, mountID)
            if ok then
                mountSpellID = spellID
                mountInfo = mountInfo or { name = name, icon = icon }
            end
        end

        if okProfile and type(mountProfile) == "table" then
            local identity = mountSpellID and tostring(mountSpellID) or tostring(mountProfileID or "current")
            add(mountProfile, "MOUNT", identity, mountInfo and mountInfo.name, mountInfo and mountInfo.icon, mountProfileID)
        elseif mountSpellID then
            addByEntity("MOUNT", tostring(mountSpellID), mountInfo and mountInfo.name, mountInfo and mountInfo.icon)
        end
    end

    return out
end

local function resolveCompanion(allowFallback)
    local companions = getActiveCompanions()
    if #companions == 0 then return nil, companions end

    local selectedKey = ChatQoL.companionKey
    if selectedKey then
        for _, companion in ipairs(companions) do
            if companion.key == selectedKey then return companion, companions end
        end
        return nil, companions
    end

    if allowFallback or ChatQoL.speaker == "COMPANION" then
        return companions[1], companions
    end

    return nil, companions
end

local function textureForCompanion(companion)
    local icon = companion and companion.icon
    if type(icon) == "number" then return icon end
    if type(icon) == "string" and icon ~= "" then
        local api = getTRP()
        if api and api.utils and type(api.utils.getIconTexture) == "function" then
            local ok, value = pcall(api.utils.getIconTexture, icon)
            if ok and type(value) == "string" and value ~= "" and not value:find("|T", 1, true) then
                return value
            end
        end
        if icon:find("\\", 1, true) or icon:find("/", 1, true) then return icon end
        return "Interface\\ICONS\\" .. icon
    end
    return "Interface\\ICONS\\INV_Box_PetCarrier_01"
end

local function getChatType(editBox)
    if not editBox or not editBox.GetAttribute then return "SAY" end
    return upper(editBox:GetAttribute("chatType") or "SAY")
end

local function getWhisperTarget(editBox)
    if not editBox then return nil end
    local target
    if type(editBox.GetTellTarget) == "function" then
        local ok, value = pcall(editBox.GetTellTarget, editBox)
        if ok then target = value end
    end
    if (not target or tostring(target) == "") and type(editBox.GetAttribute) == "function" then
        local ok, value = pcall(editBox.GetAttribute, editBox, "tellTarget")
        if ok then target = value end
    end
    if (not target or tostring(target) == "") and editBox.tellTarget then target = editBox.tellTarget end
    target = trim(target)
    return target ~= "" and target or nil
end

local function modeLabel(mode)
    mode = upper(mode)
    if mode == "EMOTE" then return "Emote" end
    if mode == "YELL" then return "Yell" end
    if mode == "WHISPER" then return "Whisper" end
    if mode == "PARTY" then return "Party" end
    if mode == "GUILD" then return "Guild" end
    if mode == "CHANNEL" then return "Channel" end
    return "Say"
end

local function getComposeMode(editBox)
    if ChatQoL.oneShotNPC then return ChatQoL.oneShotNPC.mode end
    local fallback = getChatType(editBox)
    if editBox and editBox.GetText then
        local raw = tostring(editBox:GetText() or "")
        local mode, _, explicit = ChatQoL.ParseLeadingChannel(raw, fallback)
        if explicit then return mode end
    end
    return fallback
end

local function currentSpeakerInfo(editBox)
    if ChatQoL.oneShotNPC then
        return ChatQoL.oneShotNPC.name, ChatQoL.oneShotNPC.mode, "NPC", true
    end

    local composeMode = getComposeMode(editBox)
    if ChatQoL.speaker == "COMPANION" then
        local companion = resolveCompanion()
        if companion then
            -- TRP companion speech is represented through Say/Emote. Keep the
            -- sticky selection, but show the real player as the active sender
            -- for unsupported chat types so the chip never lies about who will
            -- actually send a whisper, party message, guild message, etc.
            if composeMode == "SAY" or composeMode == "EMOTE" then
                return companion.name, composeMode, "COMPANION", false, companion
            end
            return getPlayerRPName(), composeMode, "SELF", false
        end
        ChatQoL.speaker = "SELF"
        ChatQoL.companionKey = nil
    end

    return getPlayerRPName(), composeMode, "SELF", false
end

local function updatePortrait(texture, kind, companion)
    if kind == "SELF" then
        texture:SetTexture(nil)
        if SetPortraitTexture then
            pcall(SetPortraitTexture, texture, "player")
        else
            texture:SetTexture("Interface\\ICONS\\INV_Misc_QuestionMark")
        end
    elseif kind == "COMPANION" then
        texture:SetTexture(textureForCompanion(companion))
    else
        texture:SetTexture("Interface\\ICONS\\INV_Misc_Note_01")
    end
end

local FALLBACK_THEME = {
    gold = {0.76, 0.58, 0.27, 1},
    pale = {0.90, 0.88, 0.82, 1},
    muted = {0.55, 0.57, 0.61, 1},
    panel = {0.045, 0.05, 0.062, 0.98},
    panel2 = {0.065, 0.072, 0.088, 1},
    border = {0.30, 0.31, 0.34, 1},
    button = {0.075, 0.082, 0.098, 1},
    buttonHover = {0.12, 0.13, 0.16, 1},
    accent = {0.22, 0.16, 0.08, 1},
    accentHover = {0.32, 0.23, 0.11, 1},
}

local function themePalette()
    if RPAL.GetCurrentThemePalette then
        local ok, palette = pcall(RPAL.GetCurrentThemePalette)
        if ok and type(palette) == "table" then return palette end
    end
    return FALLBACK_THEME
end

local function rgba(color, alphaOverride)
    color = type(color) == "table" and color or {1, 1, 1, 1}
    return color[1] or 1, color[2] or 1, color[3] or 1,
        alphaOverride ~= nil and alphaOverride or (color[4] or 1)
end

local function applySpeakerTheme(state)
    if not state or not state.chip then return end
    local palette = themePalette()
    if state.ring then state.ring:SetVertexColor(rgba(palette.gold, 0.95)) end
    if state.innerBG then state.innerBG:SetVertexColor(rgba(palette.panel2, 1)) end
    if state.hoverGlow then state.hoverGlow:SetVertexColor(rgba(palette.gold, 0.34)) end
    if state.status then state.status:SetTextColor(rgba(palette.pale, 1)) end
    if state.statusFrame and state.statusFrame.SetBackdropColor then
        state.statusFrame:SetBackdropColor(rgba(palette.panel, 1))
        state.statusFrame:SetBackdropBorderColor(rgba(palette.gold, 0.96))
    end
end

local function refreshChip(editBox)
    local state = editBoxState[editBox]
    if not state or not state.chip then return end
    if not isEnabled() then state.chip:Hide(); return end
    if not editBox:IsShown() then state.chip:Hide(); return end

    local name, mode, kind, once, companion = currentSpeakerInfo(editBox)
    state.currentName = name
    state.currentMode = mode
    state.currentKind = kind
    state.currentOnce = once
    updatePortrait(state.icon, kind, companion)
    applySpeakerTheme(state)

    -- A one-shot NPC gets a subtle corner marker so the player can tell at a
    -- glance that the next message is not coming from Self/Companion.
    if state.onceBadge then
        if kind == "NPC" then
            local palette = themePalette()
            state.onceBadge:SetVertexColor(rgba(palette.gold, 1))
            state.onceBadge:Show()
        else
            state.onceBadge:Hide()
        end
    end
    state.chip:Show()
end

local function refreshAllChips()
    for editBox in pairs(editBoxState) do refreshChip(editBox) end
end

local function setStatus(editBox, text)
    local state = editBoxState[editBox]
    if not state or not state.status then return end
    if not text or text == "" then
        state.status:SetText("")
        if state.statusFrame then state.statusFrame:Hide() else state.status:Hide() end
    else
        state.status:SetText(text)
        applySpeakerTheme(state)
        if state.statusFrame then state.statusFrame:Show() else state.status:Show() end
    end
end

local function closePopup()
    if popup then popup:Hide() end
end

local function focusEditBox(editBox)
    if not editBox then return end
    if ChatEdit_ActivateChat then pcall(ChatEdit_ActivateChat, editBox) end
    editBox:Show()
    editBox:SetFocus()
end

local function armNPC(editBox, name, mode)
    name = trim(name)
    if name == "" then
        printODYN("Enter an NPC name first.", true)
        return false
    end
    ChatQoL.oneShotNPC = {
        name = name,
        mode = upper(mode) == "EMOTE" and "EMOTE" or "SAY",
        returnSpeaker = ChatQoL.speaker,
        editBox = editBox,
    }
    closePopup()
    focusEditBox(editBox)
    refreshAllChips()
    return true
end

local function setStickySpeaker(kind, companionKey)
    ChatQoL.oneShotNPC = nil

    if kind == "COMPANION" then
        local companions = getActiveCompanions()
        if #companions == 0 then
            ChatQoL.speaker = "SELF"
            ChatQoL.companionKey = nil
            refreshAllChips()
            printODYN("No summoned pet or mount with a linked TRP companion profile was found.", true)
            return false
        end

        local selected
        if companionKey then
            for _, companion in ipairs(companions) do
                if companion.key == companionKey then selected = companion; break end
            end
        end
        selected = selected or companions[1]
        ChatQoL.speaker = "COMPANION"
        ChatQoL.companionKey = selected.key
    else
        ChatQoL.speaker = "SELF"
        ChatQoL.companionKey = nil
    end

    refreshAllChips()
    return true
end

local function applyPopupTheme(frame)
    if not frame then return end
    local palette = themePalette()
    if frame.SetBackdropColor then frame:SetBackdropColor(rgba(palette.panel, 0.985)) end
    if frame.SetBackdropBorderColor then frame:SetBackdropBorderColor(rgba(palette.border, 1)) end
    if frame.title then frame.title:SetTextColor(rgba(palette.pale, 1)) end
    if frame.kicker then frame.kicker:SetTextColor(rgba(palette.gold, 1)) end
    if frame.hint then frame.hint:SetTextColor(rgba(palette.muted, 1)) end
    if frame.nameLabel then frame.nameLabel:SetTextColor(rgba(palette.gold, 1)) end
    if frame.namePlaceholder then frame.namePlaceholder:SetTextColor(rgba(palette.muted, 0.82)) end
    if frame.divider then frame.divider:SetVertexColor(rgba(palette.border, 0.78)) end
    if frame.nameBG then frame.nameBG:SetVertexColor(rgba(palette.panel2, 1)) end
    if frame.nameBox then frame.nameBox:SetTextColor(rgba(palette.pale, 1)) end
    for _, button in ipairs(frame.modeButtons or {}) do
        if button._odynBG then button._odynBG:SetVertexColor(rgba(palette.button, 1)) end
        if button._odynBorder then button._odynBorder:SetVertexColor(rgba(palette.border, 0.92)) end
        if button._odynText then button._odynText:SetTextColor(rgba(palette.pale, 1)) end
    end
end

local function styleModeButton(button, label, width)
    button:SetSize(tonumber(width) or 92, 28)
    button:RegisterForClicks("LeftButtonUp")
    local bg = button:CreateTexture(nil, "BACKGROUND")
    bg:SetTexture("Interface\\Buttons\\WHITE8X8")
    bg:SetAllPoints()
    local border = button:CreateTexture(nil, "BORDER")
    border:SetTexture("Interface\\Buttons\\WHITE8X8")
    border:SetPoint("TOPLEFT", -1, 1)
    border:SetPoint("BOTTOMRIGHT", 1, -1)
    local text = button:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    text:SetPoint("CENTER", 0, 0)
    text:SetText(label)
    button._odynBG = bg
    button._odynBorder = border
    button._odynText = text
    button:SetScript("OnEnter", function(self)
        local palette = themePalette()
        self._odynBG:SetVertexColor(rgba(palette.buttonHover, 1))
        self._odynBorder:SetVertexColor(rgba(palette.gold, 0.95))
    end)
    button:SetScript("OnLeave", function(self)
        local palette = themePalette()
        self._odynBG:SetVertexColor(rgba(palette.button, 1))
        self._odynBorder:SetVertexColor(rgba(palette.border, 0.92))
    end)
end

local function createPopup()
    if popup then return popup end

    -- Compact horizontal one-shot NPC picker. It deliberately contains no
    -- message field: after choosing the name + mode the player keeps typing in
    -- the normal WoW chat box, exactly like Companion speech.
    local frame = CreateFrame("Frame", "ODYNChatSpeakerPicker", UIParent, "BackdropTemplate")
    frame:SetSize(438, 92)
    frame:SetFrameStrata("DIALOG")
    frame:SetFrameLevel(320)
    frame:SetClampedToScreen(true)
    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
    })
    frame:Hide()

    local kicker = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    kicker:SetPoint("TOPLEFT", 12, -9)
    kicker:SetText("ODYN  •  NPC SPEAK")

    local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOPRIGHT", -12, -9)
    title:SetText("NEXT MESSAGE ONLY")

    local divider = frame:CreateTexture(nil, "ARTWORK")
    divider:SetTexture("Interface\\Buttons\\WHITE8X8")
    divider:SetPoint("TOPLEFT", 12, -27)
    divider:SetPoint("TOPRIGHT", -12, -27)
    divider:SetHeight(1)

    local nameLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    nameLabel:SetPoint("LEFT", 12, 1)
    nameLabel:SetWidth(58)
    nameLabel:SetJustifyH("LEFT")
    nameLabel:SetText("NPC Name")

    local nameBG = frame:CreateTexture(nil, "BACKGROUND")
    nameBG:SetTexture("Interface\\Buttons\\WHITE8X8")
    nameBG:SetPoint("LEFT", 74, 1)
    nameBG:SetSize(208, 28)

    local nameBox = CreateFrame("EditBox", nil, frame)
    nameBox:SetSize(196, 26)
    nameBox:SetPoint("CENTER", nameBG, "CENTER", 0, 0)
    nameBox:SetAutoFocus(false)
    nameBox:SetMaxLetters(80)
    nameBox:SetFontObject(GameFontHighlightSmall)
    nameBox:SetTextInsets(6, 6, 0, 0)

    local namePlaceholder = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    namePlaceholder:SetPoint("LEFT", nameBox, "LEFT", 7, 0)
    namePlaceholder:SetText("Enter NPC name...")
    namePlaceholder:SetJustifyH("LEFT")

    local sayButton = CreateFrame("Button", nil, frame)
    styleModeButton(sayButton, "Say", 62)
    sayButton:SetPoint("LEFT", nameBG, "RIGHT", 9, 0)

    local emoteButton = CreateFrame("Button", nil, frame)
    styleModeButton(emoteButton, "Emote", 72)
    emoteButton:SetPoint("LEFT", sayButton, "RIGHT", 7, 0)

    local hint = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    hint:SetPoint("BOTTOMLEFT", 12, 7)
    hint:SetWidth(414)
    hint:SetJustifyH("LEFT")
    hint:SetWordWrap(false)
    hint:SetText("Choose Say or Emote, then type the one-shot NPC message in WoW chat.")

    sayButton:SetScript("OnClick", function() armNPC(frame.ownerEditBox, nameBox:GetText(), "SAY") end)
    emoteButton:SetScript("OnClick", function() armNPC(frame.ownerEditBox, nameBox:GetText(), "EMOTE") end)
    nameBox:SetScript("OnEnterPressed", function(self) armNPC(frame.ownerEditBox, self:GetText(), "SAY") end)
    nameBox:SetScript("OnEscapePressed", function(self) self:ClearFocus(); frame:Hide() end)
    nameBox:SetScript("OnTextChanged", function(self)
        if namePlaceholder then namePlaceholder:SetShown(trim(self:GetText()) == "") end
    end)

    frame:SetScript("OnShow", function(self)
        nameBox:SetText("")
        if namePlaceholder then namePlaceholder:Show() end
        applyPopupTheme(self)
        local owner = self.ownerEditBox
        if owner then
            owner:Show()
            refreshChip(owner)
        end
    end)
    frame:SetScript("OnHide", function()
        if nameBox then nameBox:ClearFocus() end
    end)

    frame.kicker = kicker
    frame.title = title
    frame.divider = divider
    frame.nameLabel = nameLabel
    frame.nameBG = nameBG
    frame.nameBox = nameBox
    frame.namePlaceholder = namePlaceholder
    frame.hint = hint
    frame.modeButtons = { sayButton, emoteButton }
    popup = frame
    applyPopupTheme(frame)
    return frame
end

local function togglePopup(editBox, anchor)
    local frame = createPopup()
    if frame:IsShown() and frame.ownerEditBox == editBox then
        frame:Hide()
        return
    end
    GameTooltip:Hide()
    frame.ownerEditBox = editBox
    frame:ClearAllPoints()
    frame:SetPoint("BOTTOMLEFT", editBox, "TOPLEFT", 0, 8)
    applyPopupTheme(frame)
    frame:Show()
    frame:Raise()
end

local function toggleSelfCompanion(editBox)
    if ChatQoL.oneShotNPC then
        ChatQoL.oneShotNPC = nil
        refreshAllChips()
        return
    end

    local companions = getActiveCompanions()
    if #companions == 0 then
        setStickySpeaker("SELF")
        printODYN("No summoned pet or mount with a linked TRP companion profile was found.", true)
        return
    end

    -- Contextual cycle: Self -> active pet(s) -> active mount -> Self. Only
    -- companions that are actually summoned and linked in TRP are included.
    if ChatQoL.speaker ~= "COMPANION" then
        setStickySpeaker("COMPANION", companions[1].key)
        return
    end

    local currentIndex
    for index, companion in ipairs(companions) do
        if companion.key == ChatQoL.companionKey then
            currentIndex = index
            break
        end
    end

    if currentIndex and currentIndex < #companions then
        setStickySpeaker("COMPANION", companions[currentIndex + 1].key)
    else
        setStickySpeaker("SELF")
    end
end

local function restoreComposeLimits(editBox)
    if not editBox then return end
    local state = editBoxState[editBox]
    if editBox.SetMaxLetters then
        local value = state and state.originalMaxLetters
        if value ~= nil then pcall(editBox.SetMaxLetters, editBox, value) end
    end
    if editBox.SetMaxBytes then
        local value = state and state.originalMaxBytes
        if value ~= nil then pcall(editBox.SetMaxBytes, editBox, value) end
    end
    if editBox.SetVisibleTextByteLimit then
        local value = state and state.originalVisibleTextByteLimit
        if value ~= nil then pcall(editBox.SetVisibleTextByteLimit, editBox, value) end
    end
end

local function applyComposeLimits(editBox)
    if not editBox then return end
    if not isEnabled() then
        restoreComposeLimits(editBox)
        return
    end

    -- Mainline ChatFrameEditBoxTemplate has independent limits for total bytes
    -- and *visible* text bytes. Raising SetMaxBytes alone still leaves the
    -- stock visibleBytes=255 clamp in place, which makes typing stop at the
    -- normal chat boundary. Keep letters unlimited and expand both byte caps.
    if editBox.SetMaxLetters then pcall(editBox.SetMaxLetters, editBox, 0) end
    if editBox.SetMaxBytes then pcall(editBox.SetMaxBytes, editBox, EDITBOX_MAX_BYTES) end
    if editBox.SetVisibleTextByteLimit then
        pcall(editBox.SetVisibleTextByteLimit, editBox, COMPOSE_LIMIT)
    end
end

local function makeCircularMask(owner, region, size)
    if not owner.CreateMaskTexture or not region.AddMaskTexture then return nil end
    local mask = owner:CreateMaskTexture(nil, "ARTWORK")
    mask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask")
    mask:SetSize(size, size)
    mask:SetPoint("CENTER", region, "CENTER", 0, 0)
    region:AddMaskTexture(mask)
    return mask
end

local function getSpeakerSwitchHotkey()
    local settings = getSettings()
    local binds = type(settings.keybinds) == "table" and settings.keybinds or {}
    local key = upper(binds.chatspeaker or "TAB")
    return key ~= "" and key or nil
end

local function physicalKeyString(key)
    key = upper(key)
    if key == "LALT" or key == "RALT" then key = "ALT" end
    if key == "LCTRL" or key == "RCTRL" then key = "CTRL" end
    if key == "LSHIFT" or key == "RSHIFT" then key = "SHIFT" end

    local parts = {}
    if key ~= "CTRL" and IsControlKeyDown and IsControlKeyDown() then parts[#parts + 1] = "CTRL" end
    if key ~= "ALT" and IsAltKeyDown and IsAltKeyDown() then parts[#parts + 1] = "ALT" end
    if key ~= "SHIFT" and IsShiftKeyDown and IsShiftKeyDown() then parts[#parts + 1] = "SHIFT" end
    parts[#parts + 1] = key
    return table.concat(parts, "-")
end

local function createChip(editBox)
    if editBoxState[editBox] then return editBoxState[editBox] end

    local state = { interacting = false, hoveringSpeaker = false }
    -- Capture the edit box's native limits before ODYN ever expands them so the
    -- entire Chat QoL layer can be disabled without leaving WoW's chat composer
    -- permanently modified for the rest of the login session.
    state.originalMaxLetters = 0
    state.originalMaxBytes = 255
    state.originalVisibleTextByteLimit = 255
    if editBox.GetMaxLetters then
        local ok, value = pcall(editBox.GetMaxLetters, editBox)
        if ok and value ~= nil then state.originalMaxLetters = value end
    end
    if editBox.GetMaxBytes then
        local ok, value = pcall(editBox.GetMaxBytes, editBox)
        if ok and value ~= nil then state.originalMaxBytes = value end
    end
    if editBox.GetVisibleTextByteLimit then
        local ok, value = pcall(editBox.GetVisibleTextByteLimit, editBox)
        if ok and value ~= nil then state.originalVisibleTextByteLimit = value end
    end
    -- The speaker selector lives beside the edit box instead of above the chat
    -- frame, so it can never cover incoming RP lines.
    local chip = CreateFrame("Button", nil, UIParent)
    chip:SetSize(32, 32)
    chip:SetPoint("LEFT", editBox, "RIGHT", 7, 0)
    chip:SetFrameStrata("DIALOG")
    chip:SetFrameLevel(math.max(20, (editBox:GetFrameLevel() or 1) + 7))
    chip:SetClampedToScreen(true)
    chip:EnableMouse(true)
    chip:RegisterForClicks("LeftButtonDown", "RightButtonDown")

    local ring = chip:CreateTexture(nil, "BACKGROUND")
    ring:SetTexture("Interface\\Buttons\\WHITE8X8")
    ring:SetSize(32, 32)
    ring:SetPoint("CENTER")
    makeCircularMask(chip, ring, 32)

    local innerBG = chip:CreateTexture(nil, "BORDER")
    innerBG:SetTexture("Interface\\Buttons\\WHITE8X8")
    innerBG:SetSize(28, 28)
    innerBG:SetPoint("CENTER")
    makeCircularMask(chip, innerBG, 28)

    local icon = chip:CreateTexture(nil, "ARTWORK")
    icon:SetSize(24, 24)
    icon:SetPoint("CENTER")
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    makeCircularMask(chip, icon, 24)

    local hoverGlow = chip:CreateTexture(nil, "OVERLAY")
    hoverGlow:SetTexture("Interface\\Buttons\\WHITE8X8")
    hoverGlow:SetSize(32, 32)
    hoverGlow:SetPoint("CENTER")
    makeCircularMask(chip, hoverGlow, 32)
    hoverGlow:Hide()

    local onceBadge = chip:CreateTexture(nil, "OVERLAY", nil, 2)
    onceBadge:SetTexture("Interface\\Buttons\\WHITE8X8")
    onceBadge:SetSize(7, 7)
    onceBadge:SetPoint("BOTTOMRIGHT", -1, 1)
    makeCircularMask(chip, onceBadge, 7)
    onceBadge:Hide()

    local function snapshotDraft()
        state.preservedText = editBox:GetText() or ""
        state.preservedCursor = editBox.GetCursorPosition and editBox:GetCursorPosition() or nil
    end

    local function restoreDraft(restoreFocus)
        if not editBox then return end
        -- Treat the speaker portrait as part of the compose control. Blizzard's
        -- stock edit box closes itself when it loses focus to another frame;
        -- re-show the same box and restore the exact draft before that close can
        -- become visible. Only reclaim keyboard focus for the portrait itself --
        -- the NPC name field must be allowed to own focus while its picker is open.
        if restoreFocus and ChatEdit_ActivateChat then pcall(ChatEdit_ActivateChat, editBox) end
        editBox:Show()
        if state.preservedText ~= nil and editBox:GetText() ~= state.preservedText then
            editBox:SetText(state.preservedText)
        end
        if restoreFocus and editBox.SetFocus then pcall(editBox.SetFocus, editBox) end
        if state.preservedCursor and editBox.SetCursorPosition then
            pcall(editBox.SetCursorPosition, editBox, math.min(state.preservedCursor, #(editBox:GetText() or "")))
        end
        applyComposeLimits(editBox)
        refreshChip(editBox)
        if queue and queue.editBox == editBox then setStatus(editBox, queue.statusText) end
    end

    local function restoreDraftAndFocus()
        restoreDraft(true)
    end

    local function scheduleRestore()
        restoreDraftAndFocus()
        if C_Timer and C_Timer.After then
            -- Blizzard's focus-lost/deactivate work may execute after the click
            -- handler. Reassert the compose box for a few frames so clicking the
            -- portrait never behaves like clicking outside the chat field.
            C_Timer.After(0, restoreDraftAndFocus)
            C_Timer.After(0.04, restoreDraftAndFocus)
            C_Timer.After(0.12, restoreDraftAndFocus)
        end
    end

    chip:SetScript("OnMouseDown", function(self, button)
        if not isEnabled() then return end
        snapshotDraft()
        state.interacting = true
        if button == "RightButton" then
            -- Keep the authored chat line visible, but do not steal keyboard
            -- focus back from the NPC-name input we are about to open.
            togglePopup(editBox, self)
            restoreDraft(false)
            if popup and popup:IsShown() and popup.ownerEditBox == editBox and popup.nameBox then
                if C_Timer and C_Timer.After then
                    C_Timer.After(0, function()
                        if popup and popup:IsShown() and popup.ownerEditBox == editBox and popup.nameBox then
                            popup.nameBox:SetFocus()
                        end
                    end)
                else
                    popup.nameBox:SetFocus()
                end
            end
        elseif button == "LeftButton" then
            toggleSelfCompanion(editBox)
            scheduleRestore()
        end
    end)
    chip:SetScript("OnMouseUp", function(_, button)
        if button == "RightButton" and popup and popup:IsShown() and popup.ownerEditBox == editBox then
            restoreDraft(false)
        else
            scheduleRestore()
        end
        if C_Timer and C_Timer.After then
            C_Timer.After(0.20, function() state.interacting = false end)
        else
            state.interacting = false
        end
    end)
    chip:SetScript("OnClick", nil)
    chip:SetScript("OnEnter", function(self)
        state.hoveringSpeaker = true
        state.hoverGlow:Show()
        local name = state.currentName or "Self"
        local mode = modeLabel(state.currentMode or "SAY")
        local switchKey = getSpeakerSwitchHotkey()
        local switchLabel = switchKey and ((RPAL.FormatODYNKey and RPAL.FormatODYNKey(switchKey)) or switchKey) or "Unbound"
        GameTooltip:SetOwner(self, "ANCHOR_TOPLEFT")
        GameTooltip:AddLine(name .. " · " .. mode, 1, 1, 1)
        GameTooltip:AddLine("Left-click / [" .. switchLabel .. "]: Cycle Self / active companions", 0.75, 0.78, 0.82)
        GameTooltip:AddLine("Right-click: one-shot NPC Speak", 0.75, 0.78, 0.82)
        GameTooltip:AddLine("/cs /ce · Companion | /ns /ne · NPC", 0.55, 0.72, 0.78)
        GameTooltip:Show()
    end)
    chip:SetScript("OnLeave", function()
        state.hoveringSpeaker = false
        state.hoverGlow:Hide()
        GameTooltip:Hide()
    end)

    -- Pending split continuation must genuinely cover the compose field. Use
    -- TOOLTIP strata + an opaque themed surface so Blizzard's own EditBox text
    -- can never draw in front of the prompt.
    local statusFrame = CreateFrame("Frame", nil, editBox, "BackdropTemplate")
    statusFrame:SetAllPoints(editBox)
    statusFrame:SetFrameStrata(editBox:GetFrameStrata() or "DIALOG")
    statusFrame:SetFrameLevel((editBox:GetFrameLevel() or 1) + 100)
    statusFrame:SetClampedToScreen(true)
    statusFrame:EnableMouse(false)
    statusFrame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
    })
    statusFrame:Hide()

    local status = statusFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    status:SetPoint("CENTER", 0, 0)
    status:SetWidth(math.max(120, (editBox:GetWidth() or 300) - 12))
    status:SetJustifyH("CENTER")
    status:SetWordWrap(false)
    status:SetText("")

    state.chip = chip
    state.ring = ring
    state.innerBG = innerBG
    state.icon = icon
    state.hoverGlow = hoverGlow
    state.onceBadge = onceBadge
    state.statusFrame = statusFrame
    state.status = status
    state.snapshotDraft = snapshotDraft
    state.restoreDraft = restoreDraft
    state.restoreDraftAndFocus = restoreDraftAndFocus
    editBoxState[editBox] = state

    -- TAB is the default chat-local speaker switch. Because this hook belongs
    -- to the active edit box it never steals TAB while the player is simply
    -- moving around the world. If the user changes the bind in Settings, the
    -- current value is read live on every key press.
    state.originalOnTabPressed = editBox:GetScript("OnTabPressed")
    state.tabWrapper = function(box, ...)
        local fallback = state.originalOnTabPressed
        if not isEnabled() then
            if fallback and fallback ~= state.tabWrapper then return fallback(box, ...) end
            return
        end
        local configured = getSpeakerSwitchHotkey()
        if configured == "TAB" then
            snapshotDraft()
            toggleSelfCompanion(box)
            scheduleRestore()
            return
        end
        if fallback and fallback ~= state.tabWrapper then return fallback(box, ...) end
    end
    editBox:SetScript("OnTabPressed", state.tabWrapper)

    state.originalOnKeyDown = editBox:GetScript("OnKeyDown")
    state.keyDownWrapper = function(box, key, ...)
        local fallback = state.originalOnKeyDown
        if not isEnabled() then
            if fallback and fallback ~= state.keyDownWrapper then return fallback(box, key, ...) end
            return
        end
        local configured = getSpeakerSwitchHotkey()
        if configured and configured ~= "TAB" and physicalKeyString(key) == configured then
            snapshotDraft()
            toggleSelfCompanion(box)
            scheduleRestore()
            return
        end
        if fallback and fallback ~= state.keyDownWrapper then return fallback(box, key, ...) end
    end
    editBox:SetScript("OnKeyDown", state.keyDownWrapper)

    editBox:HookScript("OnShow", function(box)
        applyComposeLimits(box)
        refreshChip(box)
        setStatus(box, queue and queue.editBox == box and queue.statusText or nil)
    end)
    editBox:HookScript("OnEditFocusGained", function(box)
        applyComposeLimits(box)
        refreshChip(box)
    end)
    -- HookScript runs *after* Blizzard's own focus-lost handler, which is too
    -- late: the stock handler has already hidden/deactivated chat by then. Wrap
    -- OnEditFocusLost itself and suppress the native close only while focus is
    -- moving to our speaker controls. Ordinary clicks elsewhere retain WoW's
    -- normal close-on-focus-loss behavior.
    state.originalOnEditFocusLost = editBox:GetScript("OnEditFocusLost")
    state.focusLostWrapper = function(box, ...)
        local fallback = state.originalOnEditFocusLost
        if not isEnabled() then
            if fallback and fallback ~= state.focusLostWrapper then return fallback(box, ...) end
            return
        end
        local popupOwnsFocus = popup and popup:IsShown() and popup.ownerEditBox == box
        if state.interacting or state.hoveringSpeaker or popupOwnsFocus then
            if popupOwnsFocus then
                restoreDraft(false)
            else
                scheduleRestore()
            end
            return
        end
        if fallback and fallback ~= state.focusLostWrapper then
            return fallback(box, ...)
        end
    end
    editBox:SetScript("OnEditFocusLost", state.focusLostWrapper)

    editBox:HookScript("OnHide", function(box)
        if not isEnabled() then
            state.chip:Hide()
            if state.statusFrame then state.statusFrame:Hide() elseif state.status then state.status:Hide() end
            return
        end
        local popupOwnsFocus = popup and popup:IsShown() and popup.ownerEditBox == box
        if state.interacting or state.hoveringSpeaker or popupOwnsFocus then
            if C_Timer and C_Timer.After then
                C_Timer.After(0, function()
                    if popupOwnsFocus then restoreDraft(false) else restoreDraftAndFocus() end
                end)
            elseif popupOwnsFocus then
                restoreDraft(false)
            else
                restoreDraftAndFocus()
            end
            return
        end
        state.chip:Hide()
        if state.statusFrame then state.statusFrame:Hide() else state.status:Hide() end
        -- Do NOT clear a pending split queue here. Mainline chat can briefly
        -- hide/re-show the edit box around send/focus transitions.
    end)
    editBox:HookScript("OnTextChanged", function(box)
        applyComposeLimits(box)
        if box:IsShown() then refreshChip(box) end
    end)
    editBox:HookScript("OnEscapePressed", function(box)
        if queue and queue.editBox == box then
            queue = nil
            setStatus(box, nil)
        end
        if ChatQoL.oneShotNPC and ChatQoL.oneShotNPC.editBox == box then
            ChatQoL.speaker = ChatQoL.oneShotNPC.returnSpeaker or ChatQoL.speaker
            ChatQoL.oneShotNPC = nil
            refreshAllChips()
        end
        if popup and popup.ownerEditBox == box then popup:Hide() end
    end)

    applySpeakerTheme(state)
    refreshChip(editBox)
    return state
end

local function configureEditBox(editBox)
    if not editBox then return end
    -- Create first so native edit-box limits are captured before ODYN expands
    -- them. This makes the Settings toggle a true reversible on/off switch.
    createChip(editBox)
    applyComposeLimits(editBox)
    -- Mainline WoW no longer sends through the legacy global
    -- ChatEdit_SendText path. The edit box's OnEnterPressed handler calls
    -- self:SendText() directly, so long-message interception must live on the
    -- actual edit box Enter path as well. installEnterHandler is assigned
    -- later in this file before PLAYER_LOGIN can fire.
    if installEnterHandler then installEnterHandler(editBox) end
end

local function scanEditBoxes()
    local maxWindows = tonumber(rawget(_G, "NUM_CHAT_WINDOWS")) or 10
    for i = 1, math.max(10, maxWindows) do
        local editBox = rawget(_G, "ChatFrame" .. i .. "EditBox")
        if editBox then configureEditBox(editBox) end
    end
    if rawget(_G, "ChatFrame1EditBox") then configureEditBox(ChatFrame1EditBox) end
end

local function addHistory(editBox, rawText, addHistory)
    if not addHistory or not rawText or rawText == "" then return end
    if editBox and type(editBox.AddHistoryLine) == "function" then
        pcall(editBox.AddHistoryLine, editBox, rawText)
    end
end

local function deactivateChat(editBox)
    if not editBox then return end
    setStatus(editBox, nil)
    if ChatEdit_DeactivateChat then
        pcall(ChatEdit_DeactivateChat, editBox)
    else
        editBox:ClearFocus()
        editBox:Hide()
    end
end

local function sendChat(message, chatType, target)
    message = tostring(message or "")
    if message == "" then return true end
    if #message > 255 then
        printODYN("A generated chat part exceeded WoW's 255-byte limit and was stopped.", true)
        return false
    end

    if C_ChatInfo and type(C_ChatInfo.SendChatMessage) == "function" then
        local ok = pcall(C_ChatInfo.SendChatMessage, message, chatType, nil, target)
        return ok
    elseif type(SendChatMessage) == "function" then
        local ok = pcall(SendChatMessage, message, chatType, nil, target)
        return ok
    end
    return false
end

local function remainingCount(q)
    if not q then return 0 end
    return math.max(0, #q.entries - q.index + 1)
end

local function updateQueueStatus(q)
    if not q or not q.editBox then return end
    local remaining = remainingCount(q)
    if remaining <= 0 then
        q.statusText = nil
        setStatus(q.editBox, nil)
    else
        q.statusText = string.format("ODYN · %d part%s remaining · [ENTER] Continue", remaining, remaining == 1 and "" or "s")
        setStatus(q.editBox, q.statusText)
    end
end

local function finishQueue(q)
    if queue ~= q then return end
    queue = nil
    setStatus(q.editBox, nil)
    if q.oneShotNPC and ChatQoL.oneShotNPC == q.oneShotNPC then
        ChatQoL.speaker = q.oneShotNPC.returnSpeaker or ChatQoL.speaker
        ChatQoL.oneShotNPC = nil
        refreshAllChips()
    end
    if q.closeWhenDone then deactivateChat(q.editBox) end
end

local function sendNext(q)
    if queue ~= q then return false end
    local entry = q.entries[q.index]
    if not entry then finishQueue(q); return true end

    if not sendChat(entry.message, entry.chatType, entry.target) then
        printODYN("The next queued chat part could not be sent.", true)
        queue = nil
        setStatus(q.editBox, nil)
        return false
    end

    q.index = q.index + 1
    if q.index > #q.entries then
        finishQueue(q)
    else
        updateQueueStatus(q)
    end
    return true
end


local function contentLimitForTRP(speakerName, mode, companion)
    -- TRP resolves a companion profile only when its NPC-speech name token is
    -- bracketed (for example: [Nibbles]). Keep that exact marker on every
    -- split part so name colour/icon/profile lookup survives continuations.
    local emptyPayload = ChatQoL.BuildTRPPayload(getTRPPrefix(), getTRPSayPattern(), speakerName, mode, "", companion)
    local overhead = #emptyPayload
    return math.max(64, SAFE_MESSAGE_BYTES - overhead - 5)
end

local function buildEntriesForTRPSpeaker(name, mode, text, companion)
    local chunks = ChatQoL.SplitText(text, contentLimitForTRP(name, mode, companion))
    local entries = {}
    local prefix = getTRPPrefix()
    local sayPattern = getTRPSayPattern()
    for _, chunk in ipairs(chunks) do
        local payload = ChatQoL.BuildTRPPayload(prefix, sayPattern, name, mode, chunk, companion)
        if #payload > 255 then
            return nil, "TRP speaker payload exceeded WoW's chat limit."
        end
        entries[#entries + 1] = { message = payload, chatType = "EMOTE" }
    end
    return entries
end

local function buildEntriesForSelf(chatType, text, target)
    local chunks = splitterEnabled() and ChatQoL.SplitText(text, SAFE_MESSAGE_BYTES) or { text }
    local entries = {}
    for _, chunk in ipairs(chunks) do
        if #chunk > 255 then return nil, "Message is longer than WoW's 255-byte chat limit." end
        entries[#entries + 1] = { message = chunk, chatType = chatType, target = target }
    end
    return entries
end

local function startQueue(editBox, entries, oneShotNPC)
    queue = {
        entries = entries,
        index = 1,
        editBox = editBox,
        closeWhenDone = true,
        oneShotNPC = oneShotNPC,
    }

    local q = queue
    if not sendNext(q) then return false end

    -- Safe input model: one hardware Enter sends one chat chunk. If more parts
    -- remain, keep the normal edit box open and focused; the player only needs
    -- to press Enter again. No timer or same-event burst auto-sends later parts.
    if queue == q then
        editBox:SetText("")
        focusEditBox(editBox)
        updateQueueStatus(q)
    end
    return true
end

local function consumeOneShotNPC()
    local oneShot = ChatQoL.oneShotNPC
    if oneShot then
        ChatQoL.speaker = oneShot.returnSpeaker or ChatQoL.speaker
        ChatQoL.oneShotNPC = nil
        refreshAllChips()
    end
end

local function reportNPCSyntax(command)
    printODYN((command or "/ns") .. " syntax: " .. (command or "/ns") .. " <NPC Name>: <message>", true)
end

function ChatQoL.HandleSend(editBox, addHistoryFlag)
    if not isEnabled() or not editBox or not editBox.GetText then return false end

    -- Safe continuation model: every hardware Enter sends exactly one queued
    -- chat part, regardless of Self / Companion / NPC or Say / Emote.
    if queue and queue.editBox == editBox then
        local typed = trim(editBox:GetText())
        if typed == "" then
            sendNext(queue)
            if queue then
                editBox:SetText("")
                focusEditBox(editBox)
            end
            return true
        else
            -- New authored text intentionally replaces the old pending queue.
            autoGeneration = autoGeneration + 1
            queue = nil
            setStatus(editBox, nil)
        end
    end

    local raw = tostring(editBox:GetText() or "")
    if trim(raw) == "" then return false end

    local special = ChatQoL.ParseSpecialCommand(raw)
    local entries, err

    if special and special.kind == "NPC_ERROR" then
        reportNPCSyntax(special.command)
        return true
    elseif special and special.kind == "COMPANION" then
        local companion = resolveCompanion(true)
        if not companion then
            printODYN("No summoned pet or mount with a linked TRP companion profile was found.", true)
            return true
        end
        entries, err = buildEntriesForTRPSpeaker(companion.name, special.mode, special.text, true)
        if not entries then printODYN(err, true); return true end
        addHistory(editBox, raw, addHistoryFlag)
        editBox:SetText("")
        startQueue(editBox, entries)
        return true
    elseif special and special.kind == "NPC" then
        if not getTRP() then
            printODYN("NPC Speak requires Total RP 3.", true)
            return true
        end
        entries, err = buildEntriesForTRPSpeaker(special.name, special.mode, special.text, false)
        if not entries then printODYN(err, true); return true end
        addHistory(editBox, raw, addHistoryFlag)
        editBox:SetText("")
        startQueue(editBox, entries)
        return true
    end

    local fallbackType = getChatType(editBox)
    local chatType, text, explicitChannel, explicitTarget = ChatQoL.ParseLeadingChannel(raw, fallbackType)
    local supported = chatType == "SAY" or chatType == "EMOTE" or chatType == "YELL" or chatType == "WHISPER"
    local whisperTarget = chatType == "WHISPER" and (explicitTarget or getWhisperTarget(editBox)) or nil

    -- Armed UI NPC always consumes exactly one authored message and ignores the
    -- current chat channel because Say/Emote was chosen in the picker itself.
    if ChatQoL.oneShotNPC then
        local npc = ChatQoL.oneShotNPC
        if not getTRP() then
            consumeOneShotNPC()
            printODYN("NPC Speak requires Total RP 3.", true)
            return true
        end
        entries, err = buildEntriesForTRPSpeaker(npc.name, npc.mode, explicitChannel and text or raw, false)
        if not entries then printODYN(err, true); return true end
        addHistory(editBox, raw, addHistoryFlag)
        editBox:SetText("")
        startQueue(editBox, entries, npc)
        return true
    end

    -- Sticky Companion only replaces the RP channels it can represent without
    -- ambiguity. Whisper/guild/party/etc. remain normal player chat.
    if ChatQoL.speaker == "COMPANION" and (chatType == "SAY" or chatType == "EMOTE") then
        local companion = resolveCompanion()
        if companion then
            entries, err = buildEntriesForTRPSpeaker(companion.name, chatType, text, true)
            if not entries then printODYN(err, true); return true end
            addHistory(editBox, raw, addHistoryFlag)
            editBox:SetText("")
            startQueue(editBox, entries)
            return true
        else
            ChatQoL.speaker = "SELF"
            refreshAllChips()
        end
    end

    if not supported then
        -- Do not reinterpret arbitrary slash commands/channels. Their native
        -- addon/WoW handlers continue untouched.
        return false
    end

    -- Long whispers must carry the recipient on every queued chunk. Short
    -- native whispers stay on Blizzard's normal send path unless explicitly
    -- authored with /w, /whisper, /t or /tell.
    if chatType == "WHISPER" and not whisperTarget then
        if #text <= 255 and not explicitChannel then return false end
        printODYN("Could not resolve the whisper recipient; reopen the whisper and try again.", true)
        return true
    end

    if not splitterEnabled() and #text <= 255 and not explicitChannel then return false end
    if #text <= 255 and not explicitChannel and ChatQoL.speaker == "SELF" then return false end

    entries, err = buildEntriesForSelf(chatType, text, whisperTarget)
    if not entries then printODYN(err, true); return true end

    addHistory(editBox, raw, addHistoryFlag)
    editBox:SetText("")
    -- One Enter sends one chunk for every RP channel/speaker type.
    startQueue(editBox, entries)
    return true
end

-- Modern/mainline WoW dispatches Enter through
-- ChatFrameEditBoxMixin:OnEnterPressed(), which calls self:SendText() directly.
-- Replacing the old ChatEdit_SendText global therefore does not see Enter on
-- current clients. Wrap the edit box script itself, while preserving Blizzard
-- (or another addon's) original handler for messages ODYN does not consume.
installEnterHandler = function(editBox)
    if not editBox or not editBox.GetScript or not editBox.SetScript then return end
    local state = editBoxState[editBox]
    if not state then return end

    local current = editBox:GetScript("OnEnterPressed")
    if current == state.enterWrapper then return end

    -- If another addon replaced the handler after ODYN, remember its newest
    -- version as the native fallback and put our thin dispatcher back on top.
    state.originalOnEnterPressed = current

    if not state.enterWrapper then
        state.enterWrapper = function(box, ...)
            -- Preserve the normal autocomplete-accept behavior. Blizzard's
            -- stock OnEnterPressed checks this before sending anything.
            if type(AutoCompleteEditBox_OnEnterPressed) == "function" then
                local ok, handled = pcall(AutoCompleteEditBox_OnEnterPressed, box)
                if ok and handled then return end
            end

            -- HandleSend returns true only when ODYN owns this Enter press
            -- (smart split queue, companion/NPC speech, queue continuation).
            -- Returning here is essential: the stock handler would otherwise
            -- clear the edit box immediately after ODYN creates the queue.
            if ChatQoL.HandleSend and ChatQoL.HandleSend(box, true) then
                return
            end

            local fallback = state.originalOnEnterPressed
            if fallback and fallback ~= state.enterWrapper then
                return fallback(box, ...)
            end
        end
    end

    editBox:SetScript("OnEnterPressed", state.enterWrapper)
end

local function installSendHook()
    if sendWrapper and rawget(_G, "ChatEdit_SendText") == sendWrapper then return end
    local current = rawget(_G, "ChatEdit_SendText")
    if type(current) ~= "function" then return end
    originalChatEditSendText = current
    sendWrapper = function(editBox, addHistoryFlag)
        if ChatQoL.HandleSend(editBox, addHistoryFlag) then return end
        return originalChatEditSendText(editBox, addHistoryFlag)
    end
    _G.ChatEdit_SendText = sendWrapper
end

local function registerSlashFallbacks()
    -- ChatEdit_SendText intercepts these before normal slash dispatch. The
    -- fallback registrations make the commands discoverable to WoW and provide
    -- useful behavior if another addon replaces ChatEdit_SendText later.
    SLASH_ODYNCOMPANIONSAY1 = "/cs"
    SlashCmdList.ODYNCOMPANIONSAY = function(message)
        if not isEnabled() then printODYN("Chat QoL is disabled in Settings.", true); return end
        local companion = resolveCompanion(true)
        if not companion then printODYN("No summoned pet or mount with a linked TRP companion profile was found.", true); return end
        local box = ChatFrame1EditBox
        local entries, err = buildEntriesForTRPSpeaker(companion.name, "SAY", trim(message), true)
        if not entries then printODYN(err, true); return end
        if box then startQueue(box, entries) end
    end

    SLASH_ODYNCOMPANIONEMOTE1 = "/ce"
    SlashCmdList.ODYNCOMPANIONEMOTE = function(message)
        if not isEnabled() then printODYN("Chat QoL is disabled in Settings.", true); return end
        local companion = resolveCompanion(true)
        if not companion then printODYN("No summoned pet or mount with a linked TRP companion profile was found.", true); return end
        local box = ChatFrame1EditBox
        local entries, err = buildEntriesForTRPSpeaker(companion.name, "EMOTE", trim(message), true)
        if not entries then printODYN(err, true); return end
        if box then startQueue(box, entries) end
    end

    SLASH_ODYN_NPCSAY1 = "/ns"
    SlashCmdList.ODYN_NPCSAY = function(message)
        if not isEnabled() then printODYN("Chat QoL is disabled in Settings.", true); return end
        local name, text = tostring(message or ""):match("^%s*(.-)%s*:%s*(.+)$")
        if not name or trim(name) == "" then reportNPCSyntax("/ns"); return end
        local box = ChatFrame1EditBox
        local entries, err = buildEntriesForTRPSpeaker(trim(name), "SAY", trim(text), false)
        if not entries then printODYN(err, true); return end
        if box then startQueue(box, entries) end
    end

    SLASH_ODYN_NPCEMOTE1 = "/ne"
    SlashCmdList.ODYN_NPCEMOTE = function(message)
        if not isEnabled() then printODYN("Chat QoL is disabled in Settings.", true); return end
        local name, text = tostring(message or ""):match("^%s*(.-)%s*:%s*(.+)$")
        if not name or trim(name) == "" then reportNPCSyntax("/ne"); return end
        local box = ChatFrame1EditBox
        local entries, err = buildEntriesForTRPSpeaker(trim(name), "EMOTE", trim(text), false)
        if not entries then printODYN(err, true); return end
        if box then startQueue(box, entries) end
    end
end

function ChatQoL.SetEnabled(enabled)
    enabled = enabled == true
    local settings = getSettings()
    settings.chatQoLEnabled = enabled

    if not enabled then
        autoGeneration = autoGeneration + 1
        if queue and queue.editBox then setStatus(queue.editBox, nil) end
        queue = nil
        ChatQoL.oneShotNPC = nil
        ChatQoL.speaker = "SELF"
        ChatQoL.companionKey = nil
        if popup then popup:Hide() end
        for editBox, state in pairs(editBoxState) do
            if state.chip then state.chip:Hide() end
            if state.statusFrame then state.statusFrame:Hide() elseif state.status then state.status:Hide() end
            restoreComposeLimits(editBox)
        end
    else
        scanEditBoxes()
        installSendHook()
        for editBox in pairs(editBoxState) do
            applyComposeLimits(editBox)
            refreshChip(editBox)
        end
    end
    return true
end
RPAL.SetChatQoLEnabled = ChatQoL.SetEnabled

function ChatQoL.RefreshTheme()
    for _, state in pairs(editBoxState) do
        applySpeakerTheme(state)
    end
    if popup then applyPopupTheme(popup) end
end
RPAL.RefreshChatQoLTheme = ChatQoL.RefreshTheme

function ChatQoL.SetSpeaker(kind)
    if not isEnabled() then return false end
    return setStickySpeaker(upper(kind) == "COMPANION" and "COMPANION" or "SELF")
end

function ChatQoL.OpenSpeakerPicker(editBox)
    if not isEnabled() then return end
    editBox = editBox or ChatFrame1EditBox
    if not editBox then return end
    configureEditBox(editBox)
    focusEditBox(editBox)
    togglePopup(editBox, editBoxState[editBox].chip)
end

function ChatQoL.CancelQueue()
    autoGeneration = autoGeneration + 1
    if queue and queue.editBox then setStatus(queue.editBox, nil) end
    queue = nil
end

eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:RegisterEvent("UPDATE_CHAT_WINDOWS")
eventFrame:RegisterEvent("PLAYER_MOUNT_DISPLAY_CHANGED")
eventFrame:RegisterEvent("COMPANION_UPDATE")
eventFrame:SetScript("OnEvent", function(_, event)
    if event == "PLAYER_LOGIN" then
        scanEditBoxes()
        installSendHook()
        registerSlashFallbacks()
        if C_Timer and C_Timer.After then
            C_Timer.After(1.0, function()
                scanEditBoxes()
                installSendHook()
                refreshAllChips()
            end)
        end
    elseif event == "UPDATE_CHAT_WINDOWS" or event == "PLAYER_ENTERING_WORLD" then
        scanEditBoxes()
        -- scanEditBoxes/configureEditBox also restores the Enter dispatcher if
        -- Blizzard or another chat addon rebuilt the edit box scripts.
        refreshAllChips()
    else
        refreshAllChips()
    end
end)
