-- SPDX-License-Identifier: CC0-1.0
-- LongChat built-in spellcheck.

local LC = _G.LongChat
if not LC then
  return
end

local Spellcheck = LC.Spellcheck or {}
LC.Spellcheck = Spellcheck

local SPELLED_WRONG_HIGHLIGHT_HEX_COLOR_CODE = "ff7dc6fb"
local SPELLED_WRONG_HIGHLIGHT = "|c" .. SPELLED_WRONG_HIGHLIGHT_HEX_COLOR_CODE

local WordCache = {}
local WordCacheCount = 0
local WordCacheCountMax = 7000
local WordLocations = setmetatable({}, { __mode = "k" })
local OldLineLength = setmetatable({}, { __mode = "k" })
local SkipOnTextChanged = setmetatable({}, { __mode = "k" })
local hookedEditBoxes = setmetatable({}, { __mode = "k" })
local suggestionsDropdown
local mouseOverlays = {}
local suggestionsPopup

local string_find = string.find
local string_gsub = string.gsub
local string_lower = string.lower
local string_match = string.match
local string_rep = string.rep
local string_sub = string.sub
local string_upper = string.upper
local tostring = tostring
local ipairs = ipairs
local pairs = pairs
local math_min = math.min

local function get_spellcheck_db()
  if LC.EnsureDB then
    LC.EnsureDB()
  end

  LC.DB.spellcheck = LC.DB.spellcheck or {}
  local db = LC.DB.spellcheck
  if db.enabled == nil then
    db.enabled = true
  end
  db.locale = db.locale or "Auto"
  db.userWords = db.userWords or {}
  db.ignoreAll = db.ignoreAll or {}
  return db
end

local function is_restricted()
  if type(LC.IsChatModificationRestricted) == "function" then
    return LC.IsChatModificationRestricted()
  end

  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

local function clear_word_locations(editbox)
  if editbox then
    WordLocations[editbox] = {}
  end
end

local function get_word_locations(editbox)
  if not editbox then
    return nil
  end
  return WordLocations[editbox]
end

local function get_suggestions_dropdown()
  if suggestionsDropdown then
    return suggestionsDropdown
  end

  suggestionsDropdown = CreateFrame("Frame", "LongChatSpellSuggestionsDropDown", UIParent, "UIDropDownMenuTemplate")
  return suggestionsDropdown
end

local function get_suggestions_popup()
  if suggestionsPopup then
    return suggestionsPopup
  end

  local frame = CreateFrame("Frame", "LongChatSpellSuggestionsPopup", UIParent, "BackdropTemplate")
  frame:SetFrameStrata("TOOLTIP")
  frame:SetFrameLevel(500)
  frame:SetClampedToScreen(true)
  frame:EnableMouse(true)
  frame:SetToplevel(true)
  frame:Hide()

  frame:SetBackdrop({
    bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    tile = true,
    tileSize = 16,
    edgeSize = 16,
    insets = { left = 4, right = 4, top = 4, bottom = 4 },
  })
  frame:SetBackdropColor(0.05, 0.05, 0.08, 0.95)
  frame:SetBackdropBorderColor(0.4, 0.45, 0.55, 1)

  local titleBg = frame:CreateTexture(nil, "ARTWORK")
  titleBg:SetPoint("TOPLEFT", 6, -6)
  titleBg:SetPoint("TOPRIGHT", -6, -6)
  titleBg:SetHeight(20)
  titleBg:SetColorTexture(0.30, 0.22, 0.02, 0.95)
  frame.titleBg = titleBg

  local title = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
  title:SetPoint("TOPLEFT", 12, -10)
  title:SetJustifyH("LEFT")
  title:SetTextColor(1.0, 0.82, 0.0, 1)
  frame.title = title

  frame.buttons = {}
  frame.rows = {}

  frame:SetScript("OnMouseDown", function(self, button)
    if button == "RightButton" or button == "LeftButton" then
      self:Hide()
    end
  end)

  suggestionsPopup = frame
  return frame
end

local function show_popup_menu(titleText, entries)
  local frame = get_suggestions_popup()
  local rows = frame.rows

  frame.title:SetText(titleText or "Suggestions")

  for i = 1, #rows do
    rows[i]:Hide()
  end

  local width = 196
  local normalRowHeight = 15
  local separatorHeight = 6
  local yOffset = -28
  local usedHeight = 0

  for i = 1, #entries do
    local row = rows[i]
    if not row then
      row = CreateFrame("Button", nil, frame)
      row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD")
      row.text = row:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
      row.text:SetPoint("LEFT", 8, 0)
      row.text:SetJustifyH("LEFT")
      row.text:SetTextColor(1, 1, 1, 1)
      rows[i] = row
    end

    row:ClearAllPoints()
    row:SetPoint("TOPLEFT", 7, yOffset - usedHeight)
    row:SetWidth(width - 14)

    if entries[i].separator then
      row:SetHeight(separatorHeight)
      row.text:SetText("")
      row:SetEnabled(false)
      row:SetScript("OnClick", nil)
      if row:GetHighlightTexture() then
        row:GetHighlightTexture():Hide()
      end
      local divider = row.divider
      if not divider then
        divider = row:CreateTexture(nil, "ARTWORK")
        divider:SetPoint("LEFT", 6, 0)
        divider:SetPoint("RIGHT", -6, 0)
        divider:SetHeight(1)
        divider:SetColorTexture(0.25, 0.25, 0.35, 0.8)
        row.divider = divider
      end
      divider:Show()
      usedHeight = usedHeight + separatorHeight
    else
      row:SetHeight(normalRowHeight)
      row.text:SetText(entries[i].text or "")
      row.text:SetTextColor(entries[i].disabled and 0.55 or 1, entries[i].disabled and 0.55 or 1, entries[i].disabled and 0.55 or 1, 1)
      row:SetEnabled(entries[i].disabled ~= true)
      row:SetScript("OnClick", function()
        frame:Hide()
        if type(entries[i].func) == "function" then
          entries[i].func()
        end
      end)
      if row.divider then
        row.divider:Hide()
      end
      usedHeight = usedHeight + normalRowHeight
    end

    row:Show()
  end

  local totalHeight = 34 + usedHeight + 8
  frame:SetSize(width, totalHeight)

  local scale = UIParent:GetEffectiveScale() or 1
  local cursorX, cursorY = GetCursorPosition()
  cursorX = (cursorX or 0) / scale
  cursorY = (cursorY or 0) / scale

  local x = cursorX + 4
  local y = cursorY - 2
  local screenWidth = UIParent:GetWidth() or 0
  local screenHeight = UIParent:GetHeight() or 0

  if x + width > screenWidth - 8 then
    x = math.max(8, cursorX - width - 6)
  end
  if y - totalHeight < 8 then
    y = math.min(screenHeight - 8, cursorY + totalHeight - 12)
  end

  frame:ClearAllPoints()
  frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x, y)
  frame:Show()
end

local function build_visible_text_map(rawText)
  if not rawText or #rawText == 0 then
    return "", {}, {}
  end

  local vis = {}
  local mapStart = {}
  local mapLen = {}
  local i = 1
  local v = 0
  local n = #rawText

  while i <= n do
    local c = string_sub(rawText, i, i)

    if c == "|" and i < n and string_sub(rawText, i + 1, i + 1) == "|" then
      v = v + 1
      vis[v] = "|"
      mapStart[v] = i
      mapLen[v] = 2
      i = i + 2
    elseif c == "|" and i + 9 <= n and (string_sub(rawText, i + 1, i + 1) == "c" or string_sub(rawText, i + 1, i + 1) == "C") then
      i = i + 10
    elseif c == "|" and i < n and string_sub(rawText, i + 1, i + 1) == "r" then
      i = i + 2
    elseif c == "|" and i < n and string_sub(rawText, i + 1, i + 1) == "H" then
      local s, e = string_find(rawText, "|H.-|h", i)
      if s == i and e then
        i = e + 1
      else
        v = v + 1
        vis[v] = c
        mapStart[v] = i
        mapLen[v] = 1
        i = i + 1
      end
    elseif c == "|" and i < n and string_sub(rawText, i + 1, i + 1) == "h" then
      i = i + 2
    elseif c == "|" and i < n and string_sub(rawText, i + 1, i + 1) == "T" then
      local s, e = string_find(rawText, "|T.-|t", i)
      if s == i and e then
        i = e + 1
      else
        v = v + 1
        vis[v] = c
        mapStart[v] = i
        mapLen[v] = 1
        i = i + 1
      end
    elseif c == "|" and i < n and string_sub(rawText, i + 1, i + 1) == "A" then
      local s, e = string_find(rawText, "|A.-|a", i)
      if s == i and e then
        i = e + 1
      else
        v = v + 1
        vis[v] = c
        mapStart[v] = i
        mapLen[v] = 1
        i = i + 1
      end
    else
      v = v + 1
      vis[v] = c
      mapStart[v] = i
      mapLen[v] = 1
      i = i + 1
    end
  end

  return table.concat(vis), mapStart, mapLen
end

local function get_word_at_visible_cursor(editbox)
  if not editbox or not editbox.GetText or not editbox.GetCursorPosition then
    return nil
  end

  local rawText = editbox:GetText() or ""
  if rawText == "" then
    return nil
  end

  local cPos = editbox:GetCursorPosition() or 0
  local visText, mapStart, mapLen = build_visible_text_map(rawText)
  if not visText or visText == "" then
    return nil
  end

  local vPos = cPos
  if vPos < 1 then vPos = 1 end
  if vPos > #visText then vPos = #visText end

  local patt = "[%a%d_']"
  if string_match(string_sub(visText, vPos, vPos), patt) == nil then
    if vPos > 1 and string_match(string_sub(visText, vPos - 1, vPos - 1), patt) ~= nil then
      vPos = vPos - 1
    else
      return nil
    end
  end

  local vStart = vPos
  while vStart > 1 and string_match(string_sub(visText, vStart - 1, vStart - 1), patt) ~= nil do
    vStart = vStart - 1
  end

  local vEnd = vPos
  while vEnd < #visText and string_match(string_sub(visText, vEnd + 1, vEnd + 1), patt) ~= nil do
    vEnd = vEnd + 1
  end

  local word = string_sub(visText, vStart, vEnd)
  if not word or word == "" then
    return nil
  end

  local rawStart = mapStart[vStart]
  local rawEnd = mapStart[vEnd] + (mapLen[vEnd] or 1) - 1
  return word, rawStart, rawEnd
end

function Spellcheck:IsRestricted()
  return is_restricted()
end

function Spellcheck:IsEnabled()
  return get_spellcheck_db().enabled == true
end

function Spellcheck:SetEnabled(enabled)
  local db = get_spellcheck_db()
  db.enabled = enabled and true or false

  if LC.FlushSettings then
    LC.FlushSettings()
  end

  if LC.UI and LC.UI.spellLabel and LC.UI.spellLabel.SetText then
    LC.UI.spellLabel:SetText(self:GetStatusText())
  end

  for editbox in pairs(hookedEditBoxes) do
    local text = editbox.GetText and editbox:GetText() or ""
    if not self:IsRestricted() then
      local cleaned, cursor = self:RemoveHighlighting(text, editbox.GetCursorPosition and editbox:GetCursorPosition() or 0)
      if cleaned ~= text then
        SkipOnTextChanged[editbox] = true
        editbox:SetText(cleaned)
        if editbox.SetCursorPosition then
          editbox:SetCursorPosition(cursor)
        end
      end
    end
    clear_word_locations(editbox)
  end

  if suggestionsPopup and suggestionsPopup.Hide then
    suggestionsPopup:Hide()
  end
end

function Spellcheck:IsAvailable()
  return self.loaded == true and self:IsEnabled() and not self:IsRestricted()
end

function Spellcheck:GetStatusText()
  if not self:IsEnabled() then
    return "LongChat spellcheck disabled."
  end

  if not self.loaded then
    return "LongChat spellcheck unavailable."
  end

  if self:IsRestricted() then
    return "LongChat spellcheck pauses in combat and instance content."
  end

  return "LongChat spellcheck active."
end

function Spellcheck:Initialize()
  if self.loaded ~= nil then
    return self.loaded
  end

  local db = get_spellcheck_db()
  local locale = db.locale
  if locale == "Auto" or not locale or locale == "" then
    locale = GetLocale and GetLocale() or "enUS"
  end

  if not WordDict or type(WordDict.Init) ~= "function" then
    self.loaded = false
    return false
  end

  local ok = pcall(WordDict.Init, WordDict, locale)
  self.loaded = ok and true or false
  self.loadedLocale = ok and locale or nil
  return self.loaded
end

function Spellcheck:GetSuggestions(word)
  if not self.loaded or type(word) ~= "string" or word == "" then
    return {}
  end

  if not WordCache[word] then
    return {}
  end

  if WordCache[word].Suggestions == nil then
    WordCache[word].Suggestions = WordDict:Suggest(word) or {}
  end

  return WordCache[word].Suggestions or {}
end

function Spellcheck:IgnoreWord(word)
  if type(word) ~= "string" or word == "" then
    return
  end

  local db = get_spellcheck_db()
  db.ignoreAll[word] = true
  WordCache[word] = { Correct = true, Suggestions = {} }
end

function Spellcheck:AddToUserDictionary(word)
  if type(word) ~= "string" or word == "" then
    return
  end

  local db = get_spellcheck_db()
  db.userWords[word] = true
  WordCache[word] = { Correct = true, Suggestions = {} }
end

function Spellcheck:RemoveHighlighting(text, ...)
  if type(text) ~= "string" or text == "" then
    if ... ~= nil then
      return text or "", (select(1, ...)) or 0
    end
    return text or ""
  end

  local newText = text
  local cPos = (...) ~= nil and (select(1, ...)) or 0
  local itemLinks = {}
  local itemLinkNum = 1

  local patt, matchPosStart, matchPosEnd
  local tokenSize
  local itemLink
  local tempToken
  local tempText
  local charsRemoved

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

  for _, pattern in ipairs(preservePatterns) do
    patt = pattern
    matchPosStart, matchPosEnd = string_find(newText, patt)
    while matchPosStart ~= nil do
      itemLink = string_sub(newText, matchPosStart, matchPosEnd)
      itemLinks[itemLinkNum] = itemLink

      tokenSize = #itemLink
      tempToken = "{<<" .. tostring(itemLinkNum)
      tempToken = tempToken .. string_rep(">", tokenSize - #tempToken - 1) .. "}"

      newText = string_gsub(newText, patt, tempToken, 1)

      itemLinkNum = itemLinkNum + 1
      matchPosStart, matchPosEnd = string_find(newText, patt)
    end
  end

  patt = SPELLED_WRONG_HIGHLIGHT .. "(.-)|r"
  matchPosStart, matchPosEnd = string_find(newText, patt)
  while matchPosStart ~= nil do
    tempText = string_gsub(newText, patt, "%1", 1)

    if #newText - #tempText ~= 0 then
      charsRemoved = 0
      if cPos >= matchPosStart then
        charsRemoved = (#newText - #tempText)
      end
      if cPos >= matchPosStart and cPos < matchPosEnd then
        charsRemoved = charsRemoved - 2
      end
      cPos = cPos - charsRemoved
    end
    newText = tempText

    matchPosStart, matchPosEnd = string_find(newText, patt)
  end

  patt = "|[Cc]%x%x%x%x%x%x%x%x"
  matchPosStart, matchPosEnd = string_find(newText, patt)
  while matchPosStart ~= nil do
    tempText = string_gsub(newText, patt, "", 1)
    if #newText - #tempText ~= 0 and cPos >= matchPosStart then
      cPos = cPos - (#newText - #tempText)
    end
    newText = tempText
    matchPosStart, matchPosEnd = string_find(newText, patt)
  end

  patt = "|r"
  matchPosStart, matchPosEnd = string_find(newText, patt)
  while matchPosStart ~= nil do
    tempText = string_gsub(newText, patt, "", 1)
    if #newText - #tempText ~= 0 and cPos >= matchPosStart then
      cPos = cPos - (#newText - #tempText)
    end
    newText = tempText
    matchPosStart, matchPosEnd = string_find(newText, patt)
  end

  if #itemLinks > 0 then
    for i, value in ipairs(itemLinks) do
      newText = string_gsub(newText, "{<<" .. tostring(i) .. ">-}", value)
    end
  end

  if cPos < 0 then
    cPos = 0
  end

  if ... ~= nil then
    return newText, cPos
  end
  return newText
end

function Spellcheck:CheckLine(text, editbox)
  clear_word_locations(editbox)

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

  local newText = text
  local WowTextMarkupEscapes = {
    ["(|cn[^:]+:.-|r)"] = "#",
    ["(|cnIQ%d:.-|r)"] = "#",
    ["(|[Cc]%x-|H.-|h.-|h|r)"] = "#",
    ["(|H.-|h)"] = "#",
    ["(|T.-|t)"] = "#",
    ["(|A.-|a)"] = "#",
    ["({.-})"] = "#",
    ["(|n)"] = "#",
  }

  for pattern, replacement in pairs(WowTextMarkupEscapes) do
    newText = string_gsub(newText, pattern, function(x) return string_rep(replacement, #x) end)
  end

  local patt = "[%a%d_']+"
  local matchPosStart, matchPosEnd = string_find(newText, patt)
  local index = 0

  while matchPosStart ~= nil do
    local word = string_sub(newText, matchPosStart, matchPosEnd)

    if word ~= string_upper(word) and string_match(word, "[%d]") == nil then
      index = index + 1
      WordLocations[editbox][index] = {
        Word = word,
        StartPos = matchPosStart,
        EndPos = matchPosEnd,
        RawStartPos = matchPosStart,
        RawEndPos = matchPosEnd,
      }

      if WordCache[word] == nil then
        local correct = false
        if word == string_upper(word) then
          correct = true
        end
        if correct == false then
          local spellDb = get_spellcheck_db()
          if spellDb.userWords[word] or spellDb.userWords[string_lower(word)] or spellDb.ignoreAll[word] or spellDb.ignoreAll[string_lower(word)] then
            correct = true
          end
        end
        if correct == false then
          correct = WordDict:Contains(word)
        end
        if correct == false then
          correct = WordDict:Contains(string_lower(word))
        end

        WordCache[word] = { Correct = correct }
        WordCacheCount = WordCacheCount + 1
      end
    end

    matchPosStart, matchPosEnd = string_find(newText, patt, matchPosEnd + 1)
  end
end

function Spellcheck:ReplaceWord(editbox, wordInfo, replacement)
  if not editbox or not wordInfo or type(replacement) ~= "string" or replacement == "" then
    return
  end

  local currentText = editbox:GetText() or ""
  local cleanedText, cursor = self:RemoveHighlighting(currentText, editbox.GetCursorPosition and editbox:GetCursorPosition() or 0)

  local startPos = wordInfo.RawStartPos or wordInfo.StartPos
  local endPos = wordInfo.RawEndPos or wordInfo.EndPos
  if not startPos or not endPos or startPos < 1 or endPos < startPos then
    return
  end

  local updated = string_sub(cleanedText, 1, startPos - 1) .. replacement .. string_sub(cleanedText, endPos + 1)
  local newCursor = startPos - 1 + #replacement

  if cursor > endPos then
    newCursor = cursor + (#replacement - (endPos - startPos + 1))
  elseif cursor < startPos then
    newCursor = cursor
  end

  SkipOnTextChanged[editbox] = true
  editbox:SetText(updated)
  if editbox.SetCursorPosition then
    editbox:SetCursorPosition(newCursor)
  end
  self:SpellCheckChat(editbox)
end

function Spellcheck:ShowSuggestionsMenu(editbox, wordInfo)
  if not editbox or not wordInfo or not wordInfo.Word then
    return
  end

  local suggestions = self:GetSuggestions(wordInfo.Word)
  local entries = {}

  if suggestions and #suggestions > 0 then
    for i = 1, math_min(#suggestions, 8) do
      local suggestion = suggestions[i]
      local replacement = suggestion and suggestion.Word
      if replacement and replacement ~= "" then
        entries[#entries + 1] = {
          text = replacement,
          func = function()
            Spellcheck:ReplaceWord(editbox, wordInfo, replacement)
          end,
        }
      end
    end
  else
    entries[#entries + 1] = {
      text = "No suggestions found",
      disabled = true,
    }
  end

  entries[#entries + 1] = { separator = true }
  entries[#entries + 1] = {
    text = "Ignore All",
    func = function()
      Spellcheck:IgnoreWord(wordInfo.Word)
      Spellcheck:SpellCheckChat(editbox)
    end,
  }
  entries[#entries + 1] = {
    text = "Add to Dictionary",
    func = function()
      Spellcheck:AddToUserDictionary(wordInfo.Word)
      Spellcheck:SpellCheckChat(editbox)
    end,
  }
  entries[#entries + 1] = { separator = true }

  entries[#entries + 1] = {
    text = CANCEL or "Cancel",
    func = function() end,
  }

  show_popup_menu(string.format("Suggestions for: %s", wordInfo.Word), entries)
end

local get_flagged_words

function Spellcheck:ShowSuggestionsBrowser(editbox)
  if not editbox then
    return
  end

  local words = get_flagged_words(editbox)
  if #words == 0 then
    return
  end

  if #words == 1 then
    self:ShowSuggestionsMenu(editbox, words[1])
    return
  end

  local entries = {}

  for i = 1, #words do
    local wordInfo = words[i]
    entries[#entries + 1] = {
      text = wordInfo.Word,
      func = function()
        Spellcheck:ShowSuggestionsMenu(editbox, wordInfo)
      end,
    }
  end

  entries[#entries + 1] = {
    text = CANCEL or "Cancel",
    func = function() end,
  }

  show_popup_menu("Flagged words", entries)
end

local function get_word_at_cursor(editbox)
  local locations = get_word_locations(editbox)
  if not locations or #locations == 0 then
    return nil
  end

  local cursor = editbox.GetCursorPosition and editbox:GetCursorPosition() or 0
  if cursor <= 0 then
    cursor = 1
  end

  for i = 1, #locations do
    local wordInfo = locations[i]
    if wordInfo.StartPos and wordInfo.EndPos and cursor >= wordInfo.StartPos and cursor <= wordInfo.EndPos then
      return wordInfo
    end
  end

  return nil
end

local function get_flagged_word_for_menu(editbox)
  local wordInfo = get_word_at_cursor(editbox)
  if wordInfo and WordCache[wordInfo.Word] and WordCache[wordInfo.Word].Correct == false then
    return wordInfo
  end

  local visibleWord, rawStart, rawEnd = get_word_at_visible_cursor(editbox)
  if visibleWord and rawStart and rawEnd then
    local cacheKey = visibleWord
    if WordCache[cacheKey] == nil then
      cacheKey = string_lower(visibleWord)
    end

    if WordCache[cacheKey] and WordCache[cacheKey].Correct == false then
      return {
        Word = cacheKey,
        StartPos = rawStart,
        EndPos = rawEnd,
        RawStartPos = rawStart,
        RawEndPos = rawEnd,
      }
    end
  end

  local locations = get_word_locations(editbox)
  if not locations or #locations == 0 then
    return nil
  end

  local firstFlagged
  local flaggedCount = 0
  for i = 1, #locations do
    local candidate = locations[i]
    if candidate and WordCache[candidate.Word] and WordCache[candidate.Word].Correct == false then
      flaggedCount = flaggedCount + 1
      if not firstFlagged then
        firstFlagged = candidate
      end
    end
  end

  if flaggedCount == 1 then
    return firstFlagged
  end

  return nil
end

get_flagged_words = function(editbox)
  local locations = get_word_locations(editbox)
  if not locations or #locations == 0 then
    return {}
  end

  local out = {}
  for i = 1, #locations do
    local candidate = locations[i]
    if candidate and WordCache[candidate.Word] and WordCache[candidate.Word].Correct == false then
      out[#out + 1] = candidate
    end
  end

  return out
end

function Spellcheck:GetFlaggedWords(editbox)
  return get_flagged_words(editbox)
end

function Spellcheck:SpellCheckChat(editbox)
  if not editbox or not self.loaded or not self:IsEnabled() or self:IsRestricted() then
    return
  end

  local editboxText = editbox:GetText()
  if type(editboxText) ~= "string" or #editboxText < 2 then
    return
  end

  local newText = editboxText
  local newCPos

  newText, newCPos = self:RemoveHighlighting(editboxText, editbox:GetCursorPosition())

  clear_word_locations(editbox)

  if editbox._LongChatSpellcheckNoInline then
    if newText ~= editboxText then
      SkipOnTextChanged[editbox] = true
      editbox:SetText(newText or "")
      editbox:SetCursorPosition(newCPos)
    end
    if string_sub(newText, 1, 1) ~= "/" then
      self:CheckLine(newText, editbox)
    end
    if LC.UI and LC.UI.RefreshSpellcheckOverlay then
      LC.UI:RefreshSpellcheckOverlay()
    end
    return
  end

  if string_sub(editboxText, 1, 1) == "/" then
    if newText ~= editboxText then
      SkipOnTextChanged[editbox] = true
      editbox:SetText(newText or "")
      editbox:SetCursorPosition(newCPos)
    end
    return
  end

  self:CheckLine(newText, editbox)

  local colorCodesAdded = 0
  local locations = get_word_locations(editbox)

  for x = #locations, 1, -1 do
    local wordInfo = locations[x]
    if WordCache[wordInfo.Word] and WordCache[wordInfo.Word].Correct == false then
      newText = string_sub(newText, 1, wordInfo.StartPos - 1)
        .. SPELLED_WRONG_HIGHLIGHT
        .. string_sub(newText, wordInfo.StartPos, wordInfo.EndPos)
        .. FONT_COLOR_CODE_CLOSE
        .. string_sub(newText, wordInfo.EndPos + 1)

      if newCPos >= wordInfo.EndPos then
        newCPos = newCPos + #SPELLED_WRONG_HIGHLIGHT + #FONT_COLOR_CODE_CLOSE
      elseif newCPos >= wordInfo.StartPos then
        newCPos = newCPos + #SPELLED_WRONG_HIGHLIGHT
      end

      colorCodesAdded = colorCodesAdded + 1
      if colorCodesAdded >= 12 then
        local trimmed = {}
        for i = x, #locations do
          trimmed[#trimmed + 1] = locations[i]
        end
        WordLocations[editbox] = trimmed
        locations = trimmed
        break
      end
    end
  end

  local added = 0
  for x = 1, #locations do
    local wordInfo = locations[x]
    if WordCache[wordInfo.Word] and WordCache[wordInfo.Word].Correct == false then
      wordInfo.StartPos = wordInfo.StartPos + added + #SPELLED_WRONG_HIGHLIGHT
      added = added + #SPELLED_WRONG_HIGHLIGHT
      wordInfo.EndPos = wordInfo.EndPos + added
      added = added + #FONT_COLOR_CODE_CLOSE
    end
  end

  if newText ~= editboxText then
    SkipOnTextChanged[editbox] = true
    editbox:SetText(newText or "")
    editbox:SetCursorPosition(newCPos)
    if editbox._LongChatComposer and LC.UI and LC.UI.ScheduleComposerSoftCursorResync then
      LC.UI:ScheduleComposerSoftCursorResync(editbox)
    end
  end
end

local function refresh_editbox(editbox)
  if not editbox then
    return
  end

  local text = editbox.GetText and editbox:GetText()
  if type(text) ~= "string" or text == "" then
    clear_word_locations(editbox)
    return
  end

  if Spellcheck:IsRestricted() or not Spellcheck:IsEnabled() or not Spellcheck.loaded then
    clear_word_locations(editbox)
    return
  end

  Spellcheck:SpellCheckChat(editbox)
end

local function on_text_changed(editbox)
  if SkipOnTextChanged[editbox] then
    SkipOnTextChanged[editbox] = nil
    return
  end

  local text = editbox:GetText() or ""
  local pos = editbox:GetCursorPosition() or 0
  local newLineLength = #text

  if editbox._LongChatSpellcheckNoInline then
    local cleaned, cleanedPos = Spellcheck:RemoveHighlighting(text, pos)
    if cleaned ~= text then
      SkipOnTextChanged[editbox] = true
      editbox:SetText(cleaned)
      if editbox.SetCursorPosition then
        editbox:SetCursorPosition(cleanedPos)
      end
      text = cleaned
    end

    clear_word_locations(editbox)
    if text ~= "" and string_sub(text, 1, 1) ~= "/" and Spellcheck.loaded and Spellcheck:IsEnabled() and not Spellcheck:IsRestricted() then
      Spellcheck:CheckLine(text, editbox)
    end
    OldLineLength[editbox] = #(editbox:GetText() or "")
    return
  end

  if newLineLength == 0 then
    clear_word_locations(editbox)
    if WordCacheCount > WordCacheCountMax then
      WordCache = {}
      WordCacheCount = 0
    end
    OldLineLength[editbox] = 0
    return
  end

  if Spellcheck:IsRestricted() or not Spellcheck:IsEnabled() or not Spellcheck.loaded then
    clear_word_locations(editbox)
    OldLineLength[editbox] = #(editbox:GetText() or "")
    return
  end

  if string_sub(text, 1, 1) == "/" then
    local cleaned = Spellcheck:RemoveHighlighting(text)
    if text ~= cleaned then
      SkipOnTextChanged[editbox] = true
      editbox:SetText(cleaned)
      if pos <= #cleaned then
        editbox:SetCursorPosition(pos)
      end
    end
    clear_word_locations(editbox)
    OldLineLength[editbox] = #(editbox:GetText() or "")
    return
  end

  if pos == #text and newLineLength - 1 == (OldLineLength[editbox] or 0) then
    local lastChar = string_sub(text, -1, -1)
    if string_match(lastChar, "[ %(%);,%.!%?:\"]") ~= nil then
      Spellcheck:SpellCheckChat(editbox)
    end
  elseif pos ~= #text then
    Spellcheck:SpellCheckChat(editbox)
  end

  OldLineLength[editbox] = #(editbox:GetText() or "")
end

local function on_escape_pressed(editbox)
  clear_word_locations(editbox)
end

local function on_enter_pressed(editbox)
  clear_word_locations(editbox)
  local cleaned = Spellcheck:RemoveHighlighting(editbox:GetText() or "")
  if cleaned ~= editbox:GetText() then
    SkipOnTextChanged[editbox] = true
    editbox:SetText(cleaned)
  end
  if editbox._LongChatComposer and not editbox._LongChatSpellcheckNoInline and C_Timer and C_Timer.After then
    C_Timer.After(0, function()
      if editbox and editbox.GetText and not Spellcheck:IsRestricted() then
        Spellcheck:SpellCheckChat(editbox)
      end
    end)
  end
end

function Spellcheck:AttachEditBox(editbox, opts)
  if not editbox or hookedEditBoxes[editbox] then
    return
  end

  opts = opts or {}

  self:Initialize()
  hookedEditBoxes[editbox] = true
  editbox._LongChatSpellcheckLiteMode = opts.lite == true
  editbox._LongChatSpellcheckNoInline = opts.noInline == true

  if editbox.EnableMouse then
    editbox:EnableMouse(true)
  end

  editbox:HookScript("OnTextChanged", on_text_changed)
  editbox:HookScript("OnEscapePressed", on_escape_pressed)
  if not editbox._LongChatSpellcheckLiteMode then
    editbox:HookScript("OnEnterPressed", on_enter_pressed)
  end
  local function try_show_spell_menu(self, button)
    if button ~= "RightButton" or Spellcheck:IsRestricted() or not Spellcheck:IsEnabled() or not Spellcheck.loaded then
      return
    end

    if self.SetFocus then
      self:SetFocus()
    end

    C_Timer.After(0, function()
      if not self or (self.IsShown and not self:IsShown()) then
        return
      end

      local wordInfo = get_flagged_word_for_menu(self)
      if wordInfo and WordCache[wordInfo.Word] and WordCache[wordInfo.Word].Correct == false then
        Spellcheck:ShowSuggestionsMenu(self, wordInfo)
        return
      end

      if #get_flagged_words(self) > 0 then
        Spellcheck:ShowSuggestionsBrowser(self)
      end
    end)
  end

  if editbox.HookScript then
    editbox:HookScript("OnMouseUp", try_show_spell_menu)
  end

  local overlayName = tostring(editbox)
  if not mouseOverlays[overlayName] and CreateFrame then
    local overlay = CreateFrame("Button", nil, editbox)
    overlay:SetAllPoints(editbox)
    overlay:EnableMouse(true)
    overlay:RegisterForClicks("AnyUp", "AnyDown")
    overlay:SetFrameStrata(editbox:GetFrameStrata() or "DIALOG")
    overlay:SetFrameLevel((editbox:GetFrameLevel() or 0) + 10)
    overlay:SetClampedToScreen(true)
    if overlay.SetPropagateMouseClicks then
      overlay:SetPropagateMouseClicks(true)
    end
    if overlay.SetPropagateMouseMotion then
      overlay:SetPropagateMouseMotion(true)
    end
    overlay:SetScript("OnMouseUp", function(_, btn)
      C_Timer.After(0, function()
        try_show_spell_menu(editbox, btn)
      end)
    end)
    mouseOverlays[overlayName] = overlay
  end

  editbox:HookScript("OnHide", function(self)
    local cleaned = Spellcheck:RemoveHighlighting(self:GetText() or "")
    if cleaned ~= self:GetText() then
      SkipOnTextChanged[self] = true
      self:SetText(cleaned)
    end
    clear_word_locations(self)
  end)

  refresh_editbox(editbox)
end

function Spellcheck:AttachChatEditBoxes()
  if not NUM_CHAT_WINDOWS then
    return
  end

  for i = 1, NUM_CHAT_WINDOWS do
    local editbox = _G["ChatFrame" .. i .. "EditBox"]
    if editbox then
      self:AttachEditBox(editbox, { lite = true })
    end
  end

  local active = ChatEdit_GetActiveWindow and ChatEdit_GetActiveWindow()
  if active then
    self:AttachEditBox(active, { lite = true })
  end
end

local stateFrame = CreateFrame("Frame")
stateFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
stateFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
stateFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
stateFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
stateFrame:SetScript("OnEvent", function()
  for editbox in pairs(hookedEditBoxes) do
    refresh_editbox(editbox)
  end
end)

hooksecurefunc("ChatEdit_ActivateChat", function(editbox)
  if LC.Spellcheck and LC.Spellcheck.AttachEditBox then
    LC.Spellcheck:AttachEditBox(editbox, { lite = true })
  end
end)

hooksecurefunc(LC, "OnAddonReady", function()
  if LC.Spellcheck and LC.Spellcheck.AttachChatEditBoxes then
    LC.Spellcheck:AttachChatEditBoxes()
  end
end)

Spellcheck:Initialize()
