-- LongChat/Core.lua
-- SPDX-License-Identifier: CC0-1.0
-- Licensed under CC0 1.0 Universal. See LICENSE for the full legal text.
-- Splits long chat messages (including /e, /s, /g, /p, /r, /i, numbered channels, and custom channels)
-- by intercepting the chat edit box send. Queues and throttles to avoid disconnects.

local ADDON_NAME = ...
local LC = _G.LongChat or {}
_G.LongChat = LC

local function character_key()
  local characterName = UnitFullName and UnitFullName("player") or UnitName("player") or "Unknown"
  local realmName = GetRealmName and GetRealmName() or "Realm"
  return tostring(characterName) .. " - " .. tostring(realmName)
end

local function copy_table(value, seen)
  if type(value) ~= "table" then
    return value
  end
  seen = seen or {}
  if seen[value] then
    return seen[value]
  end
  local copy = {}
  seen[value] = copy
  for key, item in pairs(value) do
    copy[copy_table(key, seen)] = copy_table(item, seen)
  end
  return copy
end

local function migrate_editor_field(shared, profile, key)
  if profile[key] == nil and shared[key] ~= nil then
    profile[key] = shared[key]
  end
  shared[key] = nil
end

local function clean_profile_name(name, fallback)
  name = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "")
  if name == "" then
    name = fallback or character_key()
  end
  if #name > 54 then
    name = name:sub(1, 54)
  end
  return name
end

local function default_editor_profile(name)
  return {
    profileName = clean_profile_name(name),
    channel = "SAY",
    draft = "",
    notes = "",
    history = {},
    journal = {},
    activeTab = "compose",
    clearOnSend = false,
    useWowColors = false,
    theme = "dark",
    customColor = { r = 0.08, g = 0.10, b = 0.12 },
    textColor = { r = 1, g = 1, b = 1 },
    composerSpellDisplay = "footer",
    composerSpellHighlights = true,
    fontScale = 1,
    minimap = { hide = false },
    size = { width = 660, height = 540 },
    position = { point = "CENTER", x = 0, y = 0 },
    scale = 1,
    opacity = 1,
  }
end

local function normalize_profile(profile, name, key)
  profile.profileName = clean_profile_name(profile.profileName or name, name)
  profile.characterKey = key
  profile.channel = profile.channel or "SAY"
  profile.draft = profile.draft or ""
  profile.notes = tostring(profile.notes or "")
  if profile.notes == "This is a note." then
    profile.notes = ""
  end
  profile.history = type(profile.history) == "table" and profile.history or {}
  profile.journal = type(profile.journal) == "table" and profile.journal or {}
  profile.activeTab = profile.activeTab or "compose"
  profile.clearOnSend = profile.clearOnSend == true
  profile.useWowColors = profile.useWowColors == true
  if profile.theme == nil and profile.useWowColors then
    profile.theme = "wow"
  end
  profile.theme = profile.theme or "dark"
  profile.customColor = type(profile.customColor) == "table" and profile.customColor or {}
  profile.customColor.r = math.max(0, math.min(1, tonumber(profile.customColor.r) or 0.08))
  profile.customColor.g = math.max(0, math.min(1, tonumber(profile.customColor.g) or 0.10))
  profile.customColor.b = math.max(0, math.min(1, tonumber(profile.customColor.b) or 0.12))
  profile.textColor = type(profile.textColor) == "table" and profile.textColor or {}
  profile.textColor.r = math.max(0, math.min(1, tonumber(profile.textColor.r) or 1))
  profile.textColor.g = math.max(0, math.min(1, tonumber(profile.textColor.g) or 1))
  profile.textColor.b = math.max(0, math.min(1, tonumber(profile.textColor.b) or 1))
  profile.composerSpellHighlights = profile.composerSpellHighlights ~= false
  profile.fontScale = math.max(0.80, math.min(1.60, tonumber(profile.fontScale) or 1))
  profile.minimap = profile.minimap or { hide = false }
  profile.size = profile.size or {}
  profile.size.width = math.max(620, tonumber(profile.size.width) or 660)
  profile.size.height = math.max(500, tonumber(profile.size.height) or 540)
  profile.position = profile.position or { point = "CENTER", x = 0, y = 0 }
  profile.position.point = profile.position.point or "CENTER"
  profile.position.x = tonumber(profile.position.x) or 0
  profile.position.y = tonumber(profile.position.y) or 0
  profile.scale = tonumber(profile.scale) or 1
  profile.opacity = math.max(0.35, math.min(1, tonumber(profile.opacity) or 1))

  return profile
end

local function ensure_editor_profile(db)
  local shared = db.editor
  local key = character_key()

  shared.characters = type(shared.characters) == "table" and shared.characters or {}
  shared.profiles = type(shared.profiles) == "table" and shared.profiles or {}
  shared.profileKeys = type(shared.profileKeys) == "table" and shared.profileKeys or {}
  shared.characterKey = key

  if not shared.profileMigrationComplete then
    for character, oldProfile in pairs(shared.characters) do
      if type(oldProfile) == "table" then
        local profileName = clean_profile_name(oldProfile.profileName or character, character)
        if type(shared.profiles[profileName]) ~= "table" then
          shared.profiles[profileName] = oldProfile
        end
        shared.profileKeys[character] = shared.profileKeys[character] or profileName
      end
    end
    shared.profileMigrationComplete = true
  end

  local activeProfileName = clean_profile_name(shared.profileKeys[key] or key, key)
  shared.profileKeys[key] = activeProfileName
  shared.profiles[activeProfileName] = type(shared.profiles[activeProfileName]) == "table"
    and shared.profiles[activeProfileName]
    or default_editor_profile(activeProfileName)
  local profile = shared.profiles[activeProfileName]

  migrate_editor_field(shared, profile, "channel")
  migrate_editor_field(shared, profile, "draft")
  migrate_editor_field(shared, profile, "notes")
  migrate_editor_field(shared, profile, "history")
  migrate_editor_field(shared, profile, "journal")
  migrate_editor_field(shared, profile, "activeTab")
  migrate_editor_field(shared, profile, "clearOnSend")
  migrate_editor_field(shared, profile, "useWowColors")
  migrate_editor_field(shared, profile, "theme")
  migrate_editor_field(shared, profile, "customColor")
  migrate_editor_field(shared, profile, "textColor")
  migrate_editor_field(shared, profile, "composerSpellDisplay")
  migrate_editor_field(shared, profile, "composerSpellHighlights")
  migrate_editor_field(shared, profile, "fontScale")
  migrate_editor_field(shared, profile, "minimap")
  migrate_editor_field(shared, profile, "size")
  migrate_editor_field(shared, profile, "position")
  migrate_editor_field(shared, profile, "scale")
  migrate_editor_field(shared, profile, "opacity")

  return normalize_profile(profile, activeProfileName, key)
end

local function ensure_db()
  LongChatDB = LongChatDB or {}
  LC.DB = LongChatDB

  LC.DB.maxChunk = LC.DB.maxChunk or 220
  LC.DB.useChatThrottle = LC.DB.useChatThrottle ~= false
  LC.DB.splitMarkers = LC.DB.splitMarkers ~= false
  LC.DB.editor = LC.DB.editor or {}
  ensure_editor_profile(LC.DB)
  LC.DB.spellcheck = LC.DB.spellcheck or {}
  if LC.DB.spellcheck.enabled == nil then
    LC.DB.spellcheck.enabled = true
  end
  LC.DB.spellcheck.locale = LC.DB.spellcheck.locale or "Auto"

  return LC.DB
end

LC.EnsureDB = ensure_db
function LC.CurrentEditor()
  ensure_db()
  local shared = LC.DB.editor
  return shared.profiles[shared.profileKeys[shared.characterKey]]
end
function LC.GetEditorProfileNames()
  ensure_db()
  local names = {}
  for name in pairs(LC.DB.editor.profiles or {}) do
    names[#names + 1] = name
  end
  table.sort(names)
  return names
end
function LC.GetCurrentEditorProfileName()
  ensure_db()
  local shared = LC.DB.editor
  return shared.profileKeys[shared.characterKey]
end
function LC.SetCurrentEditorProfileName(name)
  ensure_db()
  name = clean_profile_name(name)
  if type(LC.DB.editor.profiles[name]) ~= "table" then
    return false
  end
  LC.DB.editor.profileKeys[LC.DB.editor.characterKey] = name
  ensure_editor_profile(LC.DB)
  return true
end
function LC.CreateEditorProfile(name)
  ensure_db()
  name = clean_profile_name(name)
  if type(LC.DB.editor.profiles[name]) == "table" then
    return false
  end
  LC.DB.editor.profiles[name] = default_editor_profile(name)
  LC.DB.editor.profileKeys[LC.DB.editor.characterKey] = name
  ensure_editor_profile(LC.DB)
  return true
end
function LC.CopyEditorProfile(sourceName, targetName)
  ensure_db()
  sourceName = clean_profile_name(sourceName or LC.GetCurrentEditorProfileName())
  targetName = clean_profile_name(targetName)
  local source = LC.DB.editor.profiles[sourceName]
  if type(source) ~= "table" or type(LC.DB.editor.profiles[targetName]) == "table" then
    return false
  end
  local copy = copy_table(source)
  copy.profileName = targetName
  copy.characterKey = nil
  LC.DB.editor.profiles[targetName] = copy
  LC.DB.editor.profileKeys[LC.DB.editor.characterKey] = targetName
  ensure_editor_profile(LC.DB)
  return true
end
function LC.RenameEditorProfile(oldName, newName)
  ensure_db()
  oldName = clean_profile_name(oldName)
  newName = clean_profile_name(newName)
  if oldName == newName then
    return true
  end
  if type(LC.DB.editor.profiles[oldName]) ~= "table" or type(LC.DB.editor.profiles[newName]) == "table" then
    return false
  end
  LC.DB.editor.profiles[newName] = LC.DB.editor.profiles[oldName]
  LC.DB.editor.profiles[oldName] = nil
  LC.DB.editor.profiles[newName].profileName = newName
  for character, profileName in pairs(LC.DB.editor.profileKeys) do
    if profileName == oldName then
      LC.DB.editor.profileKeys[character] = newName
    end
  end
  ensure_editor_profile(LC.DB)
  return true
end
function LC.DeleteEditorProfile(name)
  ensure_db()
  name = clean_profile_name(name)
  local profiles = LC.DB.editor.profiles
  if type(profiles[name]) ~= "table" then
    return false
  end
  local fallback
  for profileName in pairs(profiles) do
    if profileName ~= name then
      fallback = profileName
      break
    end
  end
  if not fallback then
    return false
  end
  profiles[name] = nil
  for character, profileName in pairs(LC.DB.editor.profileKeys) do
    if profileName == name then
      LC.DB.editor.profileKeys[character] = fallback
    end
  end
  ensure_editor_profile(LC.DB)
  return true
end
function LC.ResetCurrentEditorProfile()
  ensure_db()
  local name = LC.GetCurrentEditorProfileName()
  LC.DB.editor.profiles[name] = default_editor_profile(name)
  ensure_editor_profile(LC.DB)
  return true
end
ensure_db()

LC.queue = LC.queue or {}
LC.ticker = LC.ticker or nil
LC.sending = LC.sending or false
LC.unlimitTicker = LC.unlimitTicker or nil
LC._sendingFromQueue = LC._sendingFromQueue or false


local DEFAULT_MAX_LETTERS = 255
local DEFAULT_MAX_BYTES = 256
local wrappedFnSet = setmetatable({}, { __mode = "k" })
local handle_chunked_editbox_send
local register_presend_hook

local function is_chat_modification_restricted()
  if InCombatLockdown and InCombatLockdown() then
    return true
  end

  if UnitAffectingCombat and UnitAffectingCombat("player") then
    return true
  end

  if C_ChatInfo and C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown() then
    return true
  end

  return false
end

LC.IsChatModificationRestricted = is_chat_modification_restricted

local function trim(s)
  if not s then return "" end
  s = tostring(s)
  s = s:gsub("^%s+", ""):gsub("%s+$", "")
  return s
end

local function safePrint(msg)
  DEFAULT_CHAT_FRAME:AddMessage("|cff88ccffLongChat|r: " .. tostring(msg))
end

local function strip_spellcheck_markup(text)
  if type(text) ~= "string" then
    return text
  end

  local spell = LC.Spellcheck
  if type(spell) == "table" and type(spell.RemoveHighlighting) == "function" then
    local ok, cleaned = pcall(function()
      return spell:RemoveHighlighting(text)
    end)
    if ok and type(cleaned) == "string" then
      return cleaned
    end
  end

  return text
end

local function visible_chat_text(text)
  text = tostring(text or "")
  text = text:gsub("|cn[^:]+:|H.-|h(.-)|h|r", "%1")
  text = text:gsub("|[Cc]%x%x%x%x%x%x%x%x|H.-|h(.-)|h|r", "%1")
  text = text:gsub("|H.-|h(.-)|h", "%1")
  text = text:gsub("|A.-|a", "")
  text = text:gsub("|T.-|t", "")
  text = text:gsub("|cn[^:]+:", "")
  text = text:gsub("|[Cc]%x%x%x%x%x%x%x%x", "")
  text = text:gsub("|r", "")
  text = text:gsub("||", "|")
  return text
end

local function visible_chat_length(text)
  return #visible_chat_text(text)
end

local function chunk_has_sendable_text(text)
  local visible = visible_chat_text(text):gsub("%s*>>%s*", " ")
  return trim(visible) ~= ""
end

local function filter_sendable_chunks(chunks)
  local out = {}
  for _, chunk in ipairs(chunks or {}) do
    if chunk_has_sendable_text(chunk) then
      table.insert(out, chunk)
    end
  end
  return out
end

local function adjust_cut_for_wow_markup(text, cut)
  text = tostring(text or "")
  cut = tonumber(cut) or 0
  if cut < 1 then
    return cut
  end

  local function choose_boundary(startPos, endPos)
    if startPos and endPos and startPos <= cut and cut < endPos then
      if startPos > 20 then
        return startPos - 1
      end
      return endPos
    end
  end

  local patterns = {
    "|cn[^:]+:|H.-|h.-|h|r",
    "|cn[^:]+:.-|r",
    "|[Cc]%x%x%x%x%x%x%x%x|H.-|h.-|h|r",
    "|H.-|h.-|h",
    "|A.-|a",
    "|T.-|t",
    "|[Cc]%x%x%x%x%x%x%x%x",
    "|r",
  }

  for _, pattern in ipairs(patterns) do
    local startPos, endPos = text:find(pattern)
    while startPos do
      local adjusted = choose_boundary(startPos, endPos)
      if adjusted then
        return adjusted
      end
      startPos, endPos = text:find(pattern, endPos + 1)
    end
  end

  return cut
end

local function split_message(msg, maxLen, chatType)
  if not msg then return {} end
  msg = tostring(msg):gsub("\r\n", "\n"):gsub("\r", "\n")
  if msg == "" then return {} end

  local useMarkers = LC.DB.splitMarkers ~= false
  local markerPrefix = ">> "
  local markerSuffix = " >>"
  local markerOverhead = useMarkers and (#markerPrefix + #markerSuffix) or 0
  local workLen = maxLen - markerOverhead
  if workLen < 20 then
    workLen = maxLen
  end
  if visible_chat_length(msg) <= workLen then
    return { msg }
  end

  local out = {}
  while #msg > workLen do
    local cut = workLen
    local sub = msg:sub(1, workLen)

    -- Prefer splitting at newline first, then other whitespace.
    local lastNewline = sub:match(".*()\n")
    if lastNewline and lastNewline > 1 then
      cut = lastNewline - 1
    else
      local lastSpace = sub:match(".*()%s+")
      if lastSpace and lastSpace > 20 then
        cut = lastSpace - 1
      end
    end

    if cut < 1 then
      cut = workLen
    end
    cut = adjust_cut_for_wow_markup(msg, cut)
    if cut < 1 then
      cut = workLen
    end

    local part = msg:sub(1, cut)
    if part ~= "" then
      table.insert(out, part)
    end

    msg = msg:sub(cut + 1)
    -- Drop only boundary separators between chunks.
    msg = msg:gsub("^[ \t\n]+", "")
    if msg == "" then break end
  end

  if msg ~= "" then
    table.insert(out, msg)
  end

  if useMarkers and #out > 1 then
    for i = 1, #out do
      if i > 1 then
        out[i] = markerPrefix .. out[i]
      end
      if i < #out then
        out[i] = out[i] .. markerSuffix
      end
    end
  end

  return out
end

local function split_for_send(msg, maxLen, chatType)
  local out = {}
  if not msg then return out end

  msg = tostring(msg):gsub("\r\n", "\n"):gsub("\r", "\n")
  for line in (msg .. "\n"):gmatch("(.-)\n") do
    if line ~= "" then
      local chunks = split_message(line, maxLen, chatType)
      for _, c in ipairs(chunks) do
        table.insert(out, c)
      end
    end
  end

  return out
end

local function strip_split_markers(text)
  text = tostring(text or ""):gsub("\r\n", "\n"):gsub("\r", "\n")
  local out = {}
  local previousContinues = false

  local function append_continuation(line)
    line = tostring(line or ""):gsub("^%s+", "")
    if line == "" then
      return
    end
    if #out == 0 then
      table.insert(out, line)
      return
    end
    if out[#out]:match("%s$") then
      out[#out] = out[#out] .. line
    else
      out[#out] = out[#out] .. " " .. line
    end
  end

  for line in (text .. "\n"):gmatch("(.-)\n") do
    local hasMarker = line:find(">>", 1, true) ~= nil
    local endsContinuation = line:find(">>%s*$") ~= nil
    line = line:gsub("%s*>>%s*", " ")
    if previousContinues or hasMarker then
      append_continuation(line)
    else
      table.insert(out, line)
    end
    previousContinues = endsContinuation
  end
  return table.concat(out, "\n"):gsub("\n$", "")
end

function LC.GetSplitPreview(msg, chatType)
  local maxChunk = tonumber(LC.DB.maxChunk) or 220
  return split_for_send(strip_spellcheck_markup(strip_split_markers(msg or "")), maxChunk, chatType or "SAY")
end

function LC.GetComposerDraftText(msg)
  return strip_spellcheck_markup(strip_split_markers(msg or ""))
end

function LC.FormatComposerDraft(msg, chatType)
  local cleaned = LC.GetComposerDraftText(msg)
  if LC.DB.splitMarkers == false then
    return cleaned
  end
  local chunks = LC.GetSplitPreview(cleaned, chatType)
  if #chunks <= 1 then
    return cleaned
  end
  return table.concat(chunks, "\n")
end

function LC.FormatComposerDraftWithCursor(msg, chatType, cursor)
  msg = tostring(msg or "")
  cursor = math.max(0, math.min(#msg, tonumber(cursor) or 0))

  local cleaned = strip_spellcheck_markup(strip_split_markers(msg))
  local cleanedBeforeCursor = strip_spellcheck_markup(strip_split_markers(msg:sub(1, cursor)))
  local cleanedCursor = #cleanedBeforeCursor
  local chunks = LC.GetSplitPreview(cleaned, chatType)
  local formatted = #chunks <= 1 and cleaned or table.concat(chunks, "\n")

  local prefixChunks = LC.GetSplitPreview(cleaned:sub(1, cleanedCursor), chatType)
  local formattedPrefix = #prefixChunks <= 1 and cleaned:sub(1, cleanedCursor) or table.concat(prefixChunks, "\n")
  return formatted, math.min(#formatted, #formattedPrefix)
end

LC.SafePrint = safePrint

function LC.SetSplitMarkers(enabled)
  LC.DB.splitMarkers = enabled == true
end

local function force_unlimited(editBox)
  if editBox then
    if editBox.SetMaxLetters and (not editBox.GetMaxLetters or editBox:GetMaxLetters() ~= 0) then
      editBox:SetMaxLetters(0 + 0)
    end

    if editBox.SetMaxBytes and (not editBox.GetMaxBytes or editBox:GetMaxBytes() ~= 0) then
      editBox:SetMaxBytes(0)
    end

    if editBox.SetVisibleTextByteLimit then
      editBox:SetVisibleTextByteLimit(0)
    end
  end
end

local function force_default_limits(editBox)
  if not editBox then return end
  if editBox.SetMaxLetters then
    editBox:SetMaxLetters(DEFAULT_MAX_LETTERS)
  end
  if editBox.SetMaxBytes then
    editBox:SetMaxBytes(DEFAULT_MAX_BYTES)
  end
end

local function apply_limits_for_chat_type(editBox)
  if not editBox or not editBox.GetAttribute then
    return
  end

  local ct = editBox:GetAttribute("chatType")
  if (ct and type(ct) == "string" and ct:find("^BN_")) or is_chat_modification_restricted() then
    force_default_limits(editBox)
  else
    force_unlimited(editBox)
  end
end

local function attach_unlimited_hooks(editBox)
  if not editBox or editBox._LongChatUnlimitedHooked then return end
  editBox._LongChatUnlimitedHooked = true

  apply_limits_for_chat_type(editBox)

  -- Blizzard may restore caps when headers/chatType update; keep forcing unlimited.
  editBox:HookScript("OnShow", function(self) apply_limits_for_chat_type(self) end)
  editBox:HookScript("OnEditFocusGained", function(self) apply_limits_for_chat_type(self) end)
  editBox:HookScript("OnKeyDown", function(self) apply_limits_for_chat_type(self) end)
  editBox:HookScript("OnTextChanged", function(self) apply_limits_for_chat_type(self) end)

  if hooksecurefunc then
    hooksecurefunc(editBox, "SetAttribute", function(self, attribute, value)
      if attribute == "chatType" then
        if type(value) == "string" and value:find("^BN_") then
          force_default_limits(self)
        else
          force_unlimited(self)
        end
      end
    end)
  end
end

local function unlock_chat_input_limits()
  if not NUM_CHAT_WINDOWS then return end
  for i = 1, NUM_CHAT_WINDOWS do
    local editBox = _G["ChatFrame" .. i .. "EditBox"]
    attach_unlimited_hooks(editBox)
    apply_limits_for_chat_type(editBox)
  end
end

local function stopSending()
  wipe(LC.queue)
  LC.sending = false
  if LC.ticker then
    LC.ticker:Cancel()
    LC.ticker = nil
  end
end


local function send_next()

  if #LC.queue == 0 then
    LC.sending = false
    if LC.ticker then
      LC.ticker:Cancel()
      LC.ticker = nil
    end
    return
  end

  local item = table.remove(LC.queue, 1)
  local ct = item.chatType or "SAY"

  -- SendChatMessage(msg, chatType, language, channelOrTarget)
  -- For WHISPER: channelOrTarget = playerName
  -- For CHANNEL: channelOrTarget = channel number
  local ctl = _G.ChatThrottleLib
  if LC.DB.useChatThrottle and ctl and ctl.SendChatMessage then
    local ok = pcall(function()
      ctl:SendChatMessage("ALERT", "LongChat", item.msg, ct, item.language, item.target)
    end)
    if ok then
      return
    end
  end

  LC._sendingFromQueue = true
  local ok, err = pcall(SendChatMessage, item.msg, ct, item.language, item.target)
  LC._sendingFromQueue = false
  if not ok then
    safePrint("Send failed: " .. tostring(err))
  end
end

local function start_sending()
  if LC.sending then return end
  LC.sending = true

  -- Immediate burst: enqueue all chunks without per-chunk timer delay.
  while #LC.queue > 0 do
    send_next()
  end

  LC.sending = false
  if LC.ticker then
    LC.ticker:Cancel()
    LC.ticker = nil
  end
end

local function schedule_start_sending()
  if LC._startSendQueued then
    return
  end

  LC._startSendQueued = true
  C_Timer.After(0, function()
    LC._startSendQueued = false
    start_sending()
  end)
end

local function enqueue_chunks(chunks, chatType, target, language)
  for _, c in ipairs(chunks) do
    table.insert(LC.queue, { msg = c, chatType = chatType, target = target, language = language })
  end
end

local function maybe_intercept_api_send(msg, chatType, language, target)

  if LC._sendingFromQueue then
    return false
  end

  if type(msg) ~= "string" then
    return false
  end

  local maxChunk = tonumber(LC.DB.maxChunk) or 220
  if visible_chat_length(msg) <= maxChunk and not msg:find("[\r\n]") then
    return false
  end

  local chunks = filter_sendable_chunks(split_for_send(msg, maxChunk, chatType))
  if #chunks == 0 then
    return false
  end

  enqueue_chunks(chunks, chatType or "SAY", target, language)
  start_sending()
  return true
end

local function install_api_guards()
  -- Intentionally no-op. Wrapping protected chat send APIs taints chat flow in combat.
  -- LongChat intercepts ChatEdit_SendText for chunked sends instead.
end

local function resolve_chat_routing(editBox, text)
  local chatType = editBox:GetAttribute("chatType") or "SAY"
  local channelTarget = editBox:GetAttribute("channelTarget")
  local tellTarget = editBox:GetAttribute("tellTarget")
  local payload = text or ""
  local recognizedSlash = false

  if chatType == "WHISPER" or chatType == "BN_WHISPER" then
    channelTarget = tellTarget
  end

  if payload:sub(1, 1) == "/" then
    local cmd, rest = payload:match("^/(%S+)%s*(.*)$")
    cmd = cmd and cmd:lower() or nil

    if cmd then
      local simple = {
        s = "SAY", say = "SAY",
        y = "YELL", yell = "YELL",
        e = "EMOTE", em = "EMOTE", me = "EMOTE", emote = "EMOTE",
        p = "PARTY", party = "PARTY",
        i = "INSTANCE_CHAT", instance = "INSTANCE_CHAT",
        raid = "RAID", ra = "RAID",
        rw = "RAID_WARNING", raidwarning = "RAID_WARNING",
        g = "GUILD", guild = "GUILD",
        o = "OFFICER", officer = "OFFICER",
        b = "BATTLEGROUND", bg = "BATTLEGROUND",
      }

      if simple[cmd] and rest ~= "" then
        chatType = simple[cmd]
        channelTarget = nil
        payload = rest
        recognizedSlash = true
      elseif cmd == "r" or cmd == "reply" then
        local replyTarget = ChatEdit_GetLastTellTarget and ChatEdit_GetLastTellTarget()
        if replyTarget and replyTarget ~= "" and rest ~= "" then
          chatType = "WHISPER"
          channelTarget = replyTarget
          payload = rest
          recognizedSlash = true
        end
      elseif cmd == "w" or cmd == "whisper" or cmd == "t" or cmd == "tell" then
        local who, body = rest:match("^(%S+)%s+(.+)$")
        if who and body then
          chatType = "WHISPER"
          channelTarget = who
          payload = body
          recognizedSlash = true
        end
      else
        local channelNum = tonumber(cmd)
        if channelNum and rest ~= "" then
          chatType = "CHANNEL"
          channelTarget = channelNum
          payload = rest
          recognizedSlash = true
        elseif GetChannelName and rest ~= "" then
          local id = select(1, GetChannelName(cmd))
          if id and id > 0 then
            chatType = "CHANNEL"
            channelTarget = id
            payload = rest
            recognizedSlash = true
          end
        end
      end
    end
  end

  return chatType, channelTarget, payload, recognizedSlash
end

LC.ResolveChatRouting = resolve_chat_routing

local function build_slash_prefix(chatType, target)
  if chatType == "SAY" then
    return "/s "
  elseif chatType == "YELL" then
    return "/y "
  elseif chatType == "EMOTE" then
    return "/e "
  elseif chatType == "PARTY" then
    return "/p "
  elseif chatType == "INSTANCE_CHAT" then
    return "/i "
  elseif chatType == "RAID" then
    return "/raid "
  elseif chatType == "RAID_WARNING" then
    return "/rw "
  elseif chatType == "GUILD" then
    return "/g "
  elseif chatType == "OFFICER" then
    return "/o "
  elseif chatType == "BATTLEGROUND" then
    return "/bg "
  elseif chatType == "WHISPER" and target and target ~= "" then
    return "/w " .. tostring(target) .. " "
  elseif chatType == "CHANNEL" and target then
    return "/" .. tostring(target) .. " "
  end
end

local function refresh_editbox_after_clear(editBox)
  if not editBox then
    return
  end

  if type(ChatEdit_OnTextChanged) == "function" then
    ChatEdit_OnTextChanged(editBox, true)
    return
  end

  local onTextChanged = editBox.GetScript and editBox:GetScript("OnTextChanged")
  if type(onTextChanged) == "function" then
    onTextChanged(editBox, true)
  end
end

local function set_clean_editbox_text(editBox, cleanedText)
  if not editBox or type(editBox.GetText) ~= "function" or type(editBox.SetText) ~= "function" then
    return
  end

  local currentText = editBox:GetText() or ""
  if currentText == cleanedText then
    return
  end

  local cursor = editBox.GetCursorPosition and editBox:GetCursorPosition() or 0
  editBox:SetText(cleanedText or "")
  if editBox.SetCursorPosition then
    local clamped = cursor
    local length = #(cleanedText or "")
    if clamped > length then
      clamped = length
    end
    if clamped < 0 then
      clamped = 0
    end
    editBox:SetCursorPosition(clamped)
  end
end

handle_chunked_editbox_send = function(origSend, editBox, addHistory)
  if not editBox then
    return origSend(editBox, addHistory)
  end

  local rawText = editBox:GetText() or ""
  local text = strip_spellcheck_markup(strip_split_markers(rawText))
  if not chunk_has_sendable_text(text) then
    editBox:SetText("")
    return origSend(editBox, addHistory)
  end
  set_clean_editbox_text(editBox, text)
  local cleaned = trim(text)
  if cleaned == "" then
    return origSend(editBox, addHistory)
  end

  local chatType, target, payload, recognizedSlash = resolve_chat_routing(editBox, text)
  if text:sub(1, 1) == "/" and not recognizedSlash then
    return origSend(editBox, addHistory)
  end

  if C_ChatInfo and C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown() then
    return origSend(editBox, addHistory)
  end

  local maxChunk = tonumber(LC.DB.maxChunk) or 220
  local payloadTrimmed = trim(payload)
  if payloadTrimmed == "" then
    return origSend(editBox, addHistory)
  end

  local hasNewlines = payload:find("[\r\n]") ~= nil
  if visible_chat_length(payload) <= maxChunk and not hasNewlines then
    return origSend(editBox, addHistory)
  end

  local chunks = filter_sendable_chunks(split_for_send(payload, maxChunk, chatType))
  if #chunks == 0 then
    return origSend(editBox, addHistory)
  end

  if addHistory then
    ChatEdit_AddHistory(editBox)
  end

  editBox:SetText("")
  refresh_editbox_after_clear(editBox)

  enqueue_chunks(chunks, chatType, target, nil)
  schedule_start_sending()
end

local function send_chunks_immediately(chunks, chatType, target, language)
  LC._sendingFromQueue = true
  for _, chunk in ipairs(chunks) do
    local ok, err = pcall(SendChatMessage, chunk, chatType or "SAY", language, target)
    if not ok then
      safePrint("Send failed: " .. tostring(err))
      break
    end
  end
  LC._sendingFromQueue = false
end

local function handle_presend_editbox(editBox)
  if not editBox or type(editBox.GetText) ~= "function" then
    return
  end

  if is_chat_modification_restricted() then
    return
  end

  local rawText = editBox:GetText() or ""
  local text = strip_spellcheck_markup(strip_split_markers(rawText))
  if not chunk_has_sendable_text(text) then
    editBox:SetText("")
    return
  end
  set_clean_editbox_text(editBox, text)
  local cleaned = trim(text)
  if cleaned == "" then
    return
  end

  local chatType, target, payload, recognizedSlash = resolve_chat_routing(editBox, text)
  if text:sub(1, 1) == "/" and not recognizedSlash then
    return
  end

  local maxChunk = tonumber(LC.DB.maxChunk) or 220
  local payloadTrimmed = trim(payload)
  if payloadTrimmed == "" then
    return
  end

  local hasNewlines = payload:find("[\r\n]") ~= nil
  if visible_chat_length(payload) <= maxChunk and not hasNewlines then
    return
  end

  local chunks = filter_sendable_chunks(split_for_send(payload, maxChunk, chatType))
  if #chunks <= 1 then
    return
  end

  if recognizedSlash then
    editBox:SetText("")
    enqueue_chunks(chunks, chatType, target, nil)
    schedule_start_sending()
    return
  end

  local finalChunk = table.remove(chunks)
  if #chunks > 0 then
    send_chunks_immediately(chunks, chatType, target, nil)
  end

  if recognizedSlash then
    local slashPrefix = build_slash_prefix(chatType, target)
    if slashPrefix then
      finalChunk = slashPrefix .. (finalChunk or "")
    end
  end

  editBox:SetText(finalChunk or "")
end

local function extract_editbox_from_callback_args(...)
  for i = 1, select("#", ...) do
    local value = select(i, ...)
    if type(value) == "table" then
      if type(value.GetText) == "function" then
        return value
      end

      if type(value.editBox) == "table" and type(value.editBox.GetText) == "function" then
        return value.editBox
      end

      if type(value.textTarget) == "table" and type(value.textTarget.GetText) == "function" then
        return value.textTarget
      end
    end
  end
end

register_presend_hook = function()
  if LC._preSendHookRegistered then
    return
  end

  local _, _, _, interfaceNumber = GetBuildInfo()
  interfaceNumber = tonumber(interfaceNumber) or 0
  if interfaceNumber < 120000 then
    return
  end

  if not EventRegistry or type(EventRegistry.RegisterCallback) ~= "function" then
    return
  end

  EventRegistry:RegisterCallback("ChatFrame.OnEditBoxPreSendText", function(...)
    local editBox = extract_editbox_from_callback_args(...)
    handle_presend_editbox(editBox)
  end, LC)

  LC._preSendHookRegistered = true
  safePrint("Retail pre-send hook active.")
end

local function composer_is_open()
  return LC.UI and LC.UI.frame and LC.UI.frame.frame and LC.UI.frame.frame:IsShown()
end

function LC.SendMessage(msg, chatType, target, language)
  local payload = trim(strip_spellcheck_markup(strip_split_markers(msg or "")))
  if payload == "" then
    return false, "Cannot send an empty message."
  end



  local chatInfo = C_ChatInfo
  if chatInfo and chatInfo.InChatMessagingLockdown then
    local isLocked = chatInfo.InChatMessagingLockdown()
    if isLocked then
      return false, "Chat messaging is currently locked down."
    end
  end

  if maybe_intercept_api_send(payload, chatType, language, target) then
    return true
  end

  local ok, err = pcall(SendChatMessage, payload, chatType or "SAY", language, target)
  if not ok then
    return false, "Send failed: " .. tostring(err)
  end

  return true
end

-- Slash commands
SLASH_LONGCHAT1 = "/lc"
SlashCmdList["LONGCHAT"] = function(msg)
  msg = trim(msg or "")
  local cmd, rest = msg:match("^(%S+)%s*(.*)$")
  cmd = cmd and cmd:lower() or ""

  if cmd == "stop" or cmd == "cancel" then
    stopSending()
    safePrint("Queue cleared.")
    return
  end

  if cmd == "status" then
    local ctlLoaded = (_G.ChatThrottleLib and _G.ChatThrottleLib.SendChatMessage) and "yes" or "no"
    local editor = LC.CurrentEditor and LC.CurrentEditor() or {}
    safePrint(("Queue: %d | sending: %s | maxChunk: %d | mode: burst | ctl: %s/%s | markers: %s | composer: %s"):format(
      #LC.queue, tostring(LC.sending), tonumber(LC.DB.maxChunk) or 220,
      tostring(LC.DB.useChatThrottle and "on" or "off"), ctlLoaded,
      tostring(LC.DB.splitMarkers and "on" or "off"),
      tostring(composer_is_open() and "open" or "closed")
    ))
    safePrint(("Composer settings | scale: %.2f | clearAfterSend: %s"):format(
      tonumber(editor.scale) or 1,
      tostring(editor.clearOnSend == true)
    ))
    return
  end

  if cmd == "window" or cmd == "show" or cmd == "composer" then
    if LC.ToggleComposer then
      LC.ToggleComposer()
    else
      safePrint("Composer UI is not loaded.")
    end
    return
  end

  if cmd == "settings" or cmd == "config" or cmd == "options" then
    if LC.OpenSettings then
      LC.OpenSettings()
    else
      safePrint("Settings UI is not loaded.")
    end
    return
  end

  if cmd == "minimap" then
    local v = trim(rest):lower()
    if v == "on" or v == "1" or v == "true" then
      if LC.SetMinimapHidden then
        LC.SetMinimapHidden(false)
      end
      safePrint("Minimap button shown.")
      return
    elseif v == "off" or v == "0" or v == "false" then
      if LC.SetMinimapHidden then
        LC.SetMinimapHidden(true)
      end
      safePrint("Minimap button hidden.")
      return
    end
    safePrint("Usage: /lc minimap <on|off>")
    return
  end

  if cmd == "chunk" and rest ~= "" then
    local n = tonumber(rest)
    if n and n >= 50 and n <= 250 then
      LC.DB.maxChunk = n
      safePrint("maxChunk set to " .. n)
    else
      safePrint("Usage: /lc chunk <50-250>")
    end
    return
  end

  if cmd == "ctl" then
    local v = trim(rest):lower()
    if v == "on" or v == "1" or v == "true" then
      LC.DB.useChatThrottle = true
      safePrint("ChatThrottleLib usage enabled.")
      return
    elseif v == "off" or v == "0" or v == "false" then
      LC.DB.useChatThrottle = false
      safePrint("ChatThrottleLib usage disabled.")
      return
    end
    safePrint("Usage: /lc ctl <on|off>")
    return
  end

  if cmd == "markers" then
    local v = trim(rest):lower()
    if v == "on" or v == "1" or v == "true" then
      LC.SetSplitMarkers(true)
      safePrint("Split markers enabled.")
      return
    elseif v == "off" or v == "0" or v == "false" then
      LC.SetSplitMarkers(false)
      safePrint("Split markers disabled.")
      return
    end
    safePrint("Usage: /lc markers <on|off>")
    return
  end

  safePrint("Commands: /lc status | /lc stop | /lc chunk <50-250> | /lc ctl <on|off> | /lc markers <on|off> | /lc composer | /lc minimap <on|off> | /lc settings")
end

if hooksecurefunc and ChatEdit_UpdateHeader then
  hooksecurefunc("ChatEdit_UpdateHeader", function(editBox)
    apply_limits_for_chat_type(editBox)
  end)
end

hooksecurefunc("ChatEdit_ActivateChat", function(editBox)
  attach_unlimited_hooks(editBox)
  apply_limits_for_chat_type(editBox)
end)

-- Basic load message
local f = CreateFrame("Frame")
f:RegisterEvent("ADDON_LOADED")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("PLAYER_LOGOUT")
f:SetScript("OnEvent", function(_, event, name)
  -- Re-assert wrappers as other addons may install their own hooks.
  ensure_db()
  install_api_guards()

  if event == "PLAYER_LOGOUT" and type(LC.FlushSettings) == "function" then
    LC.FlushSettings()
  end

  if event == "ADDON_LOADED" and name == ADDON_NAME then
    unlock_chat_input_limits()
    if not LC.unlimitTicker then
      LC.unlimitTicker = C_Timer.NewTicker(0.20, function()
        local active = ChatEdit_GetActiveWindow and ChatEdit_GetActiveWindow()
        if active then
          attach_unlimited_hooks(active)
          apply_limits_for_chat_type(active)
        end
      end)
    end
    register_presend_hook()
    if type(LC.OnAddonReady) == "function" then
      LC.OnAddonReady()
    end
    safePrint("Loaded. Normal chat works as usual, and the Composer is optional for drafting longer posts. /lc for options.")
  end
end)

function LC.OnAddonReady()
end
