local ADDON_NAME, PT = ...
local RU_ENGINE_VERSION = "1.2.4-help-window"

local FIRST_RUN_HELP_VERSION = 2

if type(PT) ~= "table" then
    _G.PartyTranslatorAddon = _G.PartyTranslatorAddon or {}
    PT = _G.PartyTranslatorAddon
end

local DEFAULTS = {
    enabled = true,
    showOriginal = true,
    party = true,
    instance = true,
    raid = false,
    whisper = true,
    translationColor = 1,
    outgoing = true,
    separateWindow = true,
    autoShowWindow = true,
    windowWidth = 366,
    windowHeight = 196,
    windowX = 0,
    windowY = 0,
    windowGeometryVersion = 3,
    fontSize = 14,
    windowAlpha = 0.38,
    windowScale = 1.0,
    backgroundColor = 1,
    backgroundR = 0.00,
    backgroundG = 0.00,
    backgroundB = 0.00,
    showMinimizeButton = true,
    autoMinimize = false,
    autoMinimizeDelay = 2.0,
    tabActiveR = 0.18,
    tabActiveG = 0.30,
    tabActiveB = 0.46,
    tabInactiveR = 0.07,
    tabInactiveG = 0.07,
    tabInactiveB = 0.08,
}

PT.TranslationColors = {
    [1] = { name = "Зелёный",      hex = "7EE787" },
    [2] = { name = "Голубой",      hex = "64D8FF" },
    [3] = { name = "Жёлтый",       hex = "FFD966" },
    [4] = { name = "Оранжевый",    hex = "FF9F43" },
    [5] = { name = "Красный",      hex = "FF5C5C" },
    [6] = { name = "Розовый",      hex = "FF82C8" },
    [7] = { name = "Фиолетовый",   hex = "C59BFF" },
    [8] = { name = "Белый",        hex = "FFFFFF" },
    [9] = { name = "Светло-серый", hex = "D0D0D0" },
    [10] = { name = "Лаймовый",    hex = "B8FF6A" },
}

PT.BackgroundColors = {
    [1] = { name = "Чёрный",          r = 0.00, g = 0.00, b = 0.00 },
    [2] = { name = "Тёмно-серый",     r = 0.08, g = 0.08, b = 0.08 },
    [3] = { name = "Графитовый",      r = 0.12, g = 0.13, b = 0.15 },
    [4] = { name = "Тёмно-синий",     r = 0.04, g = 0.08, b = 0.14 },
    [5] = { name = "Тёмно-зелёный",   r = 0.04, g = 0.11, b = 0.08 },
    [6] = { name = "Тёмно-фиолетовый",r = 0.10, g = 0.05, b = 0.13 },
    [7] = { name = "Тёмно-коричневый",r = 0.13, g = 0.08, b = 0.04 },
}

local function GetTranslationColorHex()
    local index = PartyTranslatorDB and tonumber(PartyTranslatorDB.translationColor) or DEFAULTS.translationColor
    local color = PT.TranslationColors[index] or PT.TranslationColors[DEFAULTS.translationColor]
    return color.hex
end

local function ColorTranslation(text)
    return "|cff" .. GetTranslationColorHex() .. text .. "|r"
end

local function Clamp(value, minValue, maxValue)
    value = tonumber(value)
    if not value then return minValue end
    if value < minValue then return minValue end
    if value > maxValue then return maxValue end
    return value
end

local function GetWindowFontPath()
    return STANDARD_TEXT_FONT or "Fonts/FRIZQT__.TTF"
end

local function InitDB()
    PartyTranslatorDB = PartyTranslatorDB or {}
    for k, v in pairs(DEFAULTS) do
        if PartyTranslatorDB[k] == nil then
            PartyTranslatorDB[k] = v
        end
    end

    -- Window geometry migration.
    -- 1.0.21 changes the factory size from 340x180 to 366x196.
    -- Upgrade only the previous factory geometry or an obviously broken tiny
    -- window; preserve real custom user sizes.
    local geometryVersion = tonumber(PartyTranslatorDB.windowGeometryVersion) or 0
    if geometryVersion < 3 then
        local savedWidth = tonumber(PartyTranslatorDB.windowWidth) or DEFAULTS.windowWidth
        local savedHeight = tonumber(PartyTranslatorDB.windowHeight) or DEFAULTS.windowHeight
        local wasOldFactorySize = (savedWidth == 340 and savedHeight == 180)
        local isBrokenTinySize = (savedWidth < 300 or savedHeight < 170)

        if wasOldFactorySize or isBrokenTinySize then
            PartyTranslatorDB.windowWidth = DEFAULTS.windowWidth
            PartyTranslatorDB.windowHeight = DEFAULTS.windowHeight
        end

        PartyTranslatorDB.windowGeometryVersion = 3
    end

    if PartyTranslatorDB.backgroundR == nil or PartyTranslatorDB.backgroundG == nil or PartyTranslatorDB.backgroundB == nil then
        local bgIndex = tonumber(PartyTranslatorDB.backgroundColor) or DEFAULTS.backgroundColor
        local bg = PT.BackgroundColors[bgIndex] or PT.BackgroundColors[DEFAULTS.backgroundColor]
        PartyTranslatorDB.backgroundR = bg.r
        PartyTranslatorDB.backgroundG = bg.g
        PartyTranslatorDB.backgroundB = bg.b
    end

    -- User-reported translation errors. WoW persists this table through
    -- SavedVariables on /reload or logout.
    PartyTranslatorDB.translationReports = PartyTranslatorDB.translationReports or {}
end

local function MarkFirstRunHelpSeen()
    if PartyTranslatorDB then
        PartyTranslatorDB.firstRunHelpVersion = FIRST_RUN_HELP_VERSION
    end
end

local function EnsureFirstRunHelpFrame()
    if PT.FirstRunHelpFrame then
        return PT.FirstRunHelpFrame
    end

    local frame = CreateFrame(
        "Frame",
        "PartyTranslatorFirstRunHelpFrame",
        UIParent,
        "BackdropTemplate"
    )
    frame:SetSize(590, 430)
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 25)
    frame:SetFrameStrata("DIALOG")
    frame:SetClampedToScreen(true)
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", function(self)
        self:StartMoving()
    end)
    frame:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
    end)
    frame:SetBackdrop({
        bgFile = "Interface/ChatFrame/ChatFrameBackground",
        edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
        edgeSize = 14,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    frame:SetBackdropColor(0.035, 0.035, 0.05, 0.97)
    frame:SetBackdropBorderColor(0.72, 0.56, 0.18, 1)

    local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", 18, -15)
    title:SetPoint("TOPRIGHT", -45, -15)
    title:SetJustifyH("LEFT")
    title:SetText("PartyTranslator — как сообщить об ошибке перевода")

    local closeButton = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
    closeButton:SetPoint("TOPRIGHT", -4, -4)
    closeButton:SetScript("OnClick", function()
        MarkFirstRunHelpSeen()
        frame:Hide()
    end)

    local divider = frame:CreateTexture(nil, "ARTWORK")
    divider:SetColorTexture(0.65, 0.50, 0.16, 0.65)
    divider:SetPoint("TOPLEFT", 16, -42)
    divider:SetPoint("TOPRIGHT", -16, -42)
    divider:SetHeight(1)

    local scroll = CreateFrame(
        "ScrollFrame",
        nil,
        frame,
        "UIPanelScrollFrameTemplate"
    )
    scroll:SetPoint("TOPLEFT", 18, -52)
    scroll:SetPoint("BOTTOMRIGHT", -38, 58)
    scroll:EnableMouseWheel(true)

    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(510, 1)
    scroll:SetScrollChild(content)

    local body = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    body:SetPoint("TOPLEFT", 0, 0)
    body:SetWidth(500)
    body:SetJustifyH("LEFT")
    body:SetJustifyV("TOP")
    body:SetWordWrap(true)
    body:SetSpacing(4)
    body:SetText(
        "|cffffd1001. Входящий перевод неверный|r\n"
        .. "Нажмите |cffffffffПКМ по переводу|r и выберите "
        .. "|cffffffff«Ошибка перевода»|r.\n\n"

        .. "|cffffd1002. Ваш русский текст неверно переведён на английский|r\n"
        .. "Нажмите кнопку |cffffffff!|r рядом со строкой EN.\n\n"

        .. "|cffffd1003. PartyTranslator вообще не смог перевести русский текст|r\n"
        .. "Такое сообщение |cffff4040НЕ БУДЕТ ОТПРАВЛЕНО|r в чат.\n"
        .. "Такой случай тоже отметьте кнопкой |cffffffff!|r.\n\n"

        .. "|cffffd1004. Что передать разработчику|r\n"
        .. "Через |cffffffff3–4 дня тестирования|r накопите несколько ошибок "
        .. "(желательно не отправлять файл ради 1–2 фраз), затем выполните:\n"
        .. "|cff80dfff/reload|r\n\n"
        .. "После этого пришлите файл:\n"
        .. "|cff80dfffWorld of Warcraft\\_retail_\\WTF\\Account\\<АККАУНТ>"
        .. "\\SavedVariables\\PartyTranslator.lua|r\n\n"

        .. "|cffff4040Не присылайте этот файл:|r\n"
        .. "|cffaaaaaaInterface\\AddOns\\PartyTranslator\\PartyTranslator.lua|r\n"
        .. "Это код аддона, а не журнал отмеченных ошибок.\n\n"

        .. "|cffb8b8b8Инструкцию можно снова открыть в настройках PartyTranslator "
        .. "кнопкой «Как сообщить об ошибке перевода».|r"
    )

    local bodyHeight = math.ceil(body:GetStringHeight() or 0)
    content:SetHeight(math.max(bodyHeight + 18, 1))

    local okButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    okButton:SetSize(130, 26)
    okButton:SetPoint("BOTTOM", 0, 18)
    okButton:SetText("Понятно")
    okButton:SetScript("OnClick", function()
        MarkFirstRunHelpSeen()
        frame:Hide()
    end)

    frame:SetScript("OnHide", function()
        MarkFirstRunHelpSeen()
    end)

    if UISpecialFrames then
        local alreadyRegistered = false
        for _, name in ipairs(UISpecialFrames) do
            if name == "PartyTranslatorFirstRunHelpFrame" then
                alreadyRegistered = true
                break
            end
        end
        if not alreadyRegistered then
            table.insert(UISpecialFrames, "PartyTranslatorFirstRunHelpFrame")
        end
    end

    frame:Hide()
    frame.title = title
    frame.scroll = scroll
    frame.content = content
    frame.body = body
    frame.okButton = okButton
    PT.FirstRunHelpFrame = frame
    return frame
end

function PT.ShowFirstRunHelp(force)
    if not PartyTranslatorDB then
        InitDB()
    end

    local seenVersion = tonumber(PartyTranslatorDB.firstRunHelpVersion) or 0
    if not force and seenVersion >= FIRST_RUN_HELP_VERSION then
        return false
    end

    local frame = EnsureFirstRunHelpFrame()
    if not frame then return false end

    frame:ClearAllPoints()
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 25)
    frame:Show()
    frame:Raise()
    return true
end

local function Trim(s)
    return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end

local function Normalize(s)
    s = s:gsub("’", "'")
    s = s:gsub("`", "'")
    s = string.lower(s)
    s = s:gsub("%s+", " ")
    return Trim(s)
end

local EscapePattern

local RU_UPPER = {
    ["А"]="а", ["Б"]="б", ["В"]="в", ["Г"]="г", ["Д"]="д", ["Е"]="е", ["Ё"]="ё", ["Ж"]="ж", ["З"]="з", ["И"]="и", ["Й"]="й",
    ["К"]="к", ["Л"]="л", ["М"]="м", ["Н"]="н", ["О"]="о", ["П"]="п", ["Р"]="р", ["С"]="с", ["Т"]="т", ["У"]="у", ["Ф"]="ф",
    ["Х"]="х", ["Ц"]="ц", ["Ч"]="ч", ["Ш"]="ш", ["Щ"]="щ", ["Ъ"]="ъ", ["Ы"]="ы", ["Ь"]="ь", ["Э"]="э", ["Ю"]="ю", ["Я"]="я",
}

local function LowerRU(s)
    s = string.lower(s or "")
    for upper, lower in pairs(RU_UPPER) do
        s = s:gsub(upper, lower)
    end
    s = s:gsub("’", "'"):gsub("`", "'")
    s = s:gsub("%s+", " ")
    return Trim(s)
end

local function NormalizeVerifiedKey(text, russianMode)
    local s = russianMode and LowerRU(text or "") or Normalize(text or "")
    local hasQuestion = s:match("%?+%s*$") ~= nil
    s = s:gsub("^[%s%.,!;:%~%*_]+", "")
    s = s:gsub("[%s%.,!;:%~%*_%?]+$", "")
    s = s:gsub("%s+", " ")
    s = Trim(s)
    if hasQuestion and s ~= "" then
        s = s .. "?"
    end
    return s
end

local function LookupVerifiedCorrection(direction, text, russianMode)
    local all = PT.VerifiedCorrections
    local bucket = all and all[tostring(direction or ""):upper()]
    if type(bucket) ~= "table" then return nil end
    local key = NormalizeVerifiedKey(text, russianMode)
    if key == "" then return nil end
    local value = bucket[key]
    if type(value) ~= "string" or value == "" then return nil end
    return value
end

local function IsBoundaryByte(ch)
    if not ch or ch == "" then return true end
    local b = string.byte(ch)
    if not b then return true end
    if b >= 128 then return false end
    return ch:match("[%s%p]") ~= nil
end

local function ReplaceWholeRUPhrase(text, phrase, replacement)
    local escaped = EscapePattern(phrase)
    local startPos = 1
    local out = {}
    local changed = false
    while true do
        local a, b = text:find(escaped, startPos)
        if not a then
            out[#out + 1] = text:sub(startPos)
            break
        end
        local before = a > 1 and text:sub(a - 1, a - 1) or nil
        local after = b < #text and text:sub(b + 1, b + 1) or nil
        if IsBoundaryByte(before) and IsBoundaryByte(after) then
            out[#out + 1] = text:sub(startPos, a - 1)
            out[#out + 1] = replacement
            startPos = b + 1
            changed = true
        else
            out[#out + 1] = text:sub(startPos, b)
            startPos = b + 1
        end
    end
    return table.concat(out), changed
end

EscapePattern = function(s)
    return (s:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1"))
end

local function IsAsciiLetter(ch)
    return ch and ch:match("[A-Za-z]") ~= nil
end

local function ReplaceWholePhrase(text, phrase, replacement)
    local escaped = EscapePattern(phrase)
    local startPos = 1
    local out = {}
    local changed = false

    while true do
        local a, b = text:find(escaped, startPos)
        if not a then
            out[#out + 1] = text:sub(startPos)
            break
        end

        local before = (a > 1) and text:sub(a - 1, a - 1) or nil
        local after = (b < #text) and text:sub(b + 1, b + 1) or nil
        local leftOK = not IsAsciiLetter(before)
        local rightOK = not IsAsciiLetter(after)

        if leftOK and rightOK then
            out[#out + 1] = text:sub(startPos, a - 1)
            out[#out + 1] = replacement
            startPos = b + 1
            changed = true
        else
            out[#out + 1] = text:sub(startPos, b)
            startPos = b + 1
        end
    end

    return table.concat(out), changed
end

local function ProtectHyperlinks(text)
    local protected = {}
    local function save(link)
        protected[#protected + 1] = link
        return "ptlink" .. #protected .. "x"
    end

    -- Colored and uncolored WoW hyperlinks.
    text = text:gsub("|c%x%x%x%x%x%x%x%x|H.-|h.-|h|r", save)
    text = text:gsub("|H.-|h.-|h", save)
    return text, protected
end

local function RestoreHyperlinks(text, protected)
    for i, link in ipairs(protected) do
        text = text:gsub("ptlink" .. i .. "x", link)
    end
    return text
end

local function CleanSpacing(text)
    text = text:gsub("%s+([,%.%!%?%:%;])", "%1")
    text = text:gsub("%(%s+", "(")
    text = text:gsub("%s+%)", ")")
    text = text:gsub("%s+", " ")
    return Trim(text)
end

local function AddExpandedForm(target, key, value)
    if key and key ~= "" and target[key] == nil then
        target[key] = value
    end
end

local function BuildExpandedWords()
    if PT.ExpandedWords then return end
    PT.ExpandedWords = {}
    local expanded = PT.ExpandedWords
    local directCount, expandedCount = 0, 0

    for word, translation in pairs(PT.Words or {}) do
        directCount = directCount + 1
        expanded[word] = translation
    end

    -- Build common English inflections. Direct dictionary entries always win.
    for word, translation in pairs(PT.Words or {}) do
        if type(word) == "string" and word:match("^[a-z][a-z']+$") and #word >= 3 then
            local before = 0
            local forms = {}
            forms[#forms + 1] = word .. "s"
            forms[#forms + 1] = word .. "es"
            forms[#forms + 1] = word .. "ed"
            forms[#forms + 1] = word .. "ing"
            forms[#forms + 1] = word .. "er"
            forms[#forms + 1] = word .. "est"
            forms[#forms + 1] = word .. "ly"
            forms[#forms + 1] = word .. "'s"

            if word:sub(-1) == "e" and #word > 3 then
                forms[#forms + 1] = word:sub(1, -2) .. "ing"
                forms[#forms + 1] = word .. "d"
            end
            if word:sub(-1) == "y" and #word > 3 then
                forms[#forms + 1] = word:sub(1, -2) .. "ies"
                forms[#forms + 1] = word:sub(1, -2) .. "ied"
            end
            local last = word:sub(-1)
            local prev = word:sub(-2, -2)
            if last:match("[bcdfgklmnprst]") and prev:match("[aeiou]") then
                forms[#forms + 1] = word .. last .. "ed"
                forms[#forms + 1] = word .. last .. "ing"
            end

            for _, form in ipairs(forms) do
                if expanded[form] == nil then
                    expanded[form] = translation
                    expandedCount = expandedCount + 1
                end
            end
        end
    end

    PT.DictionaryStats = PT.DictionaryStats or {}
    PT.DictionaryStats.directWords = directCount
    PT.DictionaryStats.generatedForms = expandedCount
    PT.DictionaryStats.recognizedWords = directCount + expandedCount
end

local function NormalizeChatWord(word)
    local alias = PT.Aliases and PT.Aliases[word]
    if alias then return alias end

    -- "pleeease" -> "please", "soooo" -> "soo". Only use it if dictionary knows the result.
    local compressed = word:gsub("([a-z])%1%1+", "%1%1")
    if compressed ~= word then
        if (PT.ExpandedWords and PT.ExpandedWords[compressed]) or (PT.GeneralWords and PT.GeneralWords[compressed]) or (PT.Aliases and PT.Aliases[compressed]) then
            return (PT.Aliases and PT.Aliases[compressed]) or compressed
        end
        local single = word:gsub("([a-z])%1+", "%1")
        if (PT.ExpandedWords and PT.ExpandedWords[single]) or (PT.GeneralWords and PT.GeneralWords[single]) or (PT.Aliases and PT.Aliases[single]) then
            return (PT.Aliases and PT.Aliases[single]) or single
        end
    end
    return word
end

local function TranslateWord(word)
    BuildExpandedWords()
    local normalizedWord = NormalizeChatWord(word)
    local repl = PT.ExpandedWords and PT.ExpandedWords[normalizedWord]
    if repl ~= nil then return repl, true end

    local general = PT.GeneralWords and PT.GeneralWords[normalizedWord]
    if general ~= nil then return general, true end

    -- On-demand stem fallback for forms not generated exactly.
    local candidates = {}
    if normalizedWord:sub(-2) == "'s" then candidates[#candidates + 1] = normalizedWord:sub(1, -3) end
    if normalizedWord:sub(-3) == "ies" then candidates[#candidates + 1] = normalizedWord:sub(1, -4) .. "y" end
    if normalizedWord:sub(-3) == "ied" then candidates[#candidates + 1] = normalizedWord:sub(1, -4) .. "y" end
    if normalizedWord:sub(-3) == "ing" then
        local stem = normalizedWord:sub(1, -4)
        candidates[#candidates + 1] = stem
        candidates[#candidates + 1] = stem .. "e"
        if #stem >= 2 and stem:sub(-1) == stem:sub(-2, -2) then candidates[#candidates + 1] = stem:sub(1, -2) end
    end
    if normalizedWord:sub(-2) == "ed" then
        local stem = normalizedWord:sub(1, -3)
        candidates[#candidates + 1] = stem
        candidates[#candidates + 1] = stem .. "e"
        if #stem >= 2 and stem:sub(-1) == stem:sub(-2, -2) then candidates[#candidates + 1] = stem:sub(1, -2) end
    end
    if normalizedWord:sub(-2) == "es" then candidates[#candidates + 1] = normalizedWord:sub(1, -3) end
    if normalizedWord:sub(-1) == "s" then candidates[#candidates + 1] = normalizedWord:sub(1, -2) end
    if normalizedWord:sub(-2) == "ly" then candidates[#candidates + 1] = normalizedWord:sub(1, -3) end
    if normalizedWord:sub(-2) == "er" then candidates[#candidates + 1] = normalizedWord:sub(1, -3) end
    if normalizedWord:sub(-3) == "est" then candidates[#candidates + 1] = normalizedWord:sub(1, -4) end

    for _, stem in ipairs(candidates) do
        local mapped = PT.Aliases and PT.Aliases[stem] or stem
        local value = PT.ExpandedWords and PT.ExpandedWords[mapped]
        if value ~= nil then return value, true end
        local generalValue = PT.GeneralWords and PT.GeneralWords[mapped]
        if generalValue ~= nil then return generalValue, true end
    end
    return word, false
end


-- ============================================================================
-- EN -> RU semantic layer
--
-- Incoming English chat is interpreted before GeneralDictionary. This keeps
-- WoW shorthand and conversational constructions from being translated
-- literally word-by-word.
-- ============================================================================

local EN_SEMANTIC_PHRASES = {
    ["omw"] = "уже иду",
    ["on my way"] = "уже иду",

    ["a summon would be appreciated"] = "суммон был бы кстати",
    ["summon would be appreciated"] = "суммон был бы кстати",
    ["a summon would be nice"] = "суммон был бы кстати",
    ["summon would be nice"] = "суммон был бы кстати",

    ["i could use a summon"] = "суммон был бы кстати",
    ["could use a summon"] = "суммон был бы кстати",

    ["ignore that"] = "забей",
    ["never mind"] = "забей",
    ["nevermind"] = "забей",
    ["nvm"] = "забей",

    ["i have tp"] = "у меня есть тп",
    ["i've got tp"] = "у меня есть тп",
    ["ive got tp"] = "у меня есть тп",
    ["i got tp"] = "у меня есть тп",

    ["i have teleport"] = "у меня есть телепорт",
    ["i've got teleport"] = "у меня есть телепорт",
    ["ive got teleport"] = "у меня есть телепорт",

    ["in fact"] = "вообще-то",
    ["actually"] = "вообще-то",
}

local function NormalizeENSemanticText(text)
    local s = Normalize(text or "")

    s = s:gsub("’", "'")
    s = s:gsub("%f[%a]summ%f[^%a]", "summon")
    s = s:gsub("%s+", " ")

    return Trim(s)
end

local function TranslateENSemanticClause(text)
    local s = NormalizeENSemanticText(text)
    if s == "" then return nil end

    local direct = EN_SEMANTIC_PHRASES[s]
    if direct then return direct end

    -- "in fact / actually" is discourse context, not literal "в факт".
    local rest = s:match("^in fact%s+(.+)$")
        or s:match("^actually%s+(.+)$")
    if rest then
        local translated = TranslateENSemanticClause(rest)
        if translated then
            return "вообще-то " .. translated
        end
    end

    -- "ignore that, I have TP" / "nvm, I have TP".
    local tail = s:match("^ignore that%s+(.+)$")
        or s:match("^never mind%s+(.+)$")
        or s:match("^nevermind%s+(.+)$")
        or s:match("^nvm%s+(.+)$")
    if tail then
        local translatedTail = TranslateENSemanticClause(tail)
        if translatedTail then
            return "забей, " .. translatedTail
        end
    end

    return nil
end

local function TranslateENSemanticIntent(text)
    local s = NormalizeENSemanticText(text)
    if s == "" then return nil end

    -- Most specific combined structures first.
    if s == "omw but a summon would be appreciated"
        or s == "omw but summon would be appreciated"
        or s == "on my way but a summon would be appreciated"
        or s == "on my way but summon would be appreciated" then
        return "уже иду, но суммон был бы кстати", 50
    end

    if s == "in fact ignore that i have tp"
        or s == "actually ignore that i have tp"
        or s == "in fact nvm i have tp"
        or s == "actually nvm i have tp" then
        return "вообще-то забей, у меня есть тп", 50
    end

    local direct = TranslateENSemanticClause(s)
    if direct then
        return direct, 40
    end

    -- Generic "A but B" semantic composition.
    local left, right = s:match("^(.+)%s+but%s+(.+)$")
    if left and right then
        local a = TranslateENSemanticClause(left)
        local b = TranslateENSemanticClause(right)
        if a and b then
            return a .. ", но " .. b, 45
        end
    end

    return nil
end

function PT.Translate(message)
    if type(message) ~= "string" or message == "" then
        return nil
    end

    -- Ignore Russian/Cyrillic-only messages; this version is EN -> RU.
    if not message:find("[A-Za-z]") then
        return nil
    end

    local protectedMessage, links = ProtectHyperlinks(message)
    local normalized = Normalize(protectedMessage)

    local verified = LookupVerifiedCorrection("EN_RU", protectedMessage, false)
    if verified then
        return RestoreHyperlinks(verified, links), 200
    end

    local exact = PT.Exact and PT.Exact[normalized]
    if exact then
        return RestoreHyperlinks(exact, links), 100
    end

    -- Semantic WoW/chat interpretation before the large ordinary dictionary.
    local semanticTranslation, semanticHits = TranslateENSemanticIntent(normalized)
    if semanticTranslation then
        return RestoreHyperlinks(semanticTranslation, links), semanticHits or 40
    end

    local generalExact = PT.GeneralExact and PT.GeneralExact[normalized]
    if generalExact then
        return RestoreHyperlinks(generalExact, links), 80
    end

    local translated = normalized
    local hits = 0

    local semanticFragments = {
        { "on my way", "уже иду" },
        { "a summon would be appreciated", "суммон был бы кстати" },
        { "summon would be appreciated", "суммон был бы кстати" },
        { "never mind", "забей" },
        { "i have tp", "у меня есть тп" },
    }
    for _, rule in ipairs(semanticFragments) do
        local newText, changed = ReplaceWholePhrase(translated, rule[1], rule[2])
        if changed then
            translated = newText
            hits = hits + 4
        end
    end

    if PT.Phrases then
        for _, rule in ipairs(PT.Phrases) do
            local newText, changed = ReplaceWholePhrase(translated, rule[1], rule[2])
            if changed then
                translated = newText
                hits = hits + 3
            end
        end
    end

    translated = translated:gsub("[A-Za-z][A-Za-z']*", function(word)
        local repl, found = TranslateWord(word)
        if found then
            hits = hits + 1
            return repl
        end
        return word
    end)

    translated = CleanSpacing(translated)
    translated = RestoreHyperlinks(translated, links)

    if hits == 0 or translated == "" then
        return nil
    end

    return translated, hits
end

local function BuildReverseDictionary()
    if PT.ReverseBuilt then return end
    PT.ReverseBuilt = true
    PT.RUExact = PT.RUExact or {}
    PT.RUWords = PT.RUWords or {}
    PT.RUPhrases = PT.RUPhrases or {}

    -- Exact translations produced by the EN -> RU dictionary are useful in reverse too.
    for english, russian in pairs(PT.Exact or {}) do
        if type(russian) == "string" and russian ~= "" and PT.RUExact[LowerRU(russian)] == nil then
            PT.RUExact[LowerRU(russian)] = english
        end
    end

    -- Add reverse phrase rules. Manual RU rules stay first and therefore win.
    local existing = {}
    for _, rule in ipairs(PT.RUPhrases) do existing[LowerRU(rule[1])] = true end
    for _, rule in ipairs(PT.Phrases or {}) do
        local english, russian = rule[1], rule[2]
        if type(russian) == "string" and russian ~= "" then
            local key = LowerRU(russian)
            if not existing[key] then
                PT.RUPhrases[#PT.RUPhrases + 1] = { key, english }
                existing[key] = true
            end
        end
    end
    table.sort(PT.RUPhrases, function(a, b) return #a[1] > #b[1] end)

    -- Reverse single-word/local terms from the large dictionary. Manual mappings win.
    for english, russian in pairs(PT.Words or {}) do
        if type(russian) == "string" and russian ~= "" then
            local key = LowerRU(russian)
            if key ~= "" and not key:find(" ", 1, true) and PT.RUWords[key] == nil then
                PT.RUWords[key] = english
            end
        end
    end
end

local function CleanEnglishSpacing(text)
    text = text:gsub("%s+([,%.%!%?%:%;])", "%1")
    text = text:gsub("%(%s+", "(")
    text = text:gsub("%s+%)", ")")
    text = text:gsub("%s+", " ")
    return Trim(text)
end

local function ContainsCyrillic(text)
    if type(text) ~= "string" or text == "" then return false end
    return text:find("\208", 1, true) ~= nil or text:find("\209", 1, true) ~= nil
end

-- ============================================================================
-- Offline language detection + partial foreign-language -> RU translation.
--
-- Important behavior:
--   * detection is conservative for short/ambiguous messages;
--   * known fragments are translated;
--   * unknown fragments are preserved verbatim;
--   * no external EXE/API is required for normal users.
-- ============================================================================

local LATIN_UPPER = {
    ["À"]="à", ["Á"]="á", ["Â"]="â", ["Ã"]="ã", ["Ä"]="ä", ["Å"]="å",
    ["Æ"]="æ", ["Ç"]="ç", ["È"]="è", ["É"]="é", ["Ê"]="ê", ["Ë"]="ë",
    ["Ì"]="ì", ["Í"]="í", ["Î"]="î", ["Ï"]="ï", ["Ñ"]="ñ",
    ["Ò"]="ò", ["Ó"]="ó", ["Ô"]="ô", ["Õ"]="õ", ["Ö"]="ö", ["Œ"]="œ",
    ["Ù"]="ù", ["Ú"]="ú", ["Û"]="û", ["Ü"]="ü", ["Ý"]="ý", ["Ÿ"]="ÿ",
    ["Ą"]="ą", ["Ć"]="ć", ["Ę"]="ę", ["Ł"]="ł", ["Ń"]="ń", ["Ś"]="ś",
    ["Ź"]="ź", ["Ż"]="ż",
}

local LATIN_FOLD = {
    ["à"]="a", ["á"]="a", ["â"]="a", ["ã"]="a", ["ä"]="a", ["å"]="a",
    ["æ"]="ae", ["ç"]="c", ["è"]="e", ["é"]="e", ["ê"]="e", ["ë"]="e",
    ["ì"]="i", ["í"]="i", ["î"]="i", ["ï"]="i", ["ñ"]="n",
    ["ò"]="o", ["ó"]="o", ["ô"]="o", ["õ"]="o", ["ö"]="o", ["œ"]="oe",
    ["ù"]="u", ["ú"]="u", ["û"]="u", ["ü"]="u", ["ý"]="y", ["ÿ"]="y",
    ["ß"]="ss", ["ą"]="a", ["ć"]="c", ["ę"]="e", ["ł"]="l", ["ń"]="n",
    ["ś"]="s", ["ź"]="z", ["ż"]="z",
}

local function LowerForeign(text)
    local s = string.lower(tostring(text or ""))
    for upper, lower in pairs(LATIN_UPPER) do
        s = s:gsub(upper, lower)
    end
    s = s:gsub("’", "'"):gsub("`", "'")
    s = s:gsub("%s+", " ")
    return Trim(s)
end

local function FoldLatin(text)
    local s = LowerForeign(text)
    for source, target in pairs(LATIN_FOLD) do
        s = s:gsub(source, target)
    end
    return s
end

local function DetectionText(text)
    local folded = FoldLatin(text)
    folded = folded:gsub("[^a-z0-9'%-]+", " ")
    folded = folded:gsub("%s+", " ")
    return Trim(folded)
end

local function AddLanguageScore(scores, code, amount)
    scores[code] = (scores[code] or 0) + (tonumber(amount) or 0)
end

local function ScoreProfile(flat, profile)
    if not profile then return 0 end
    local score = 0

    for token in flat:gmatch("%S+") do
        local weight = profile.detect and profile.detect[token]
        if weight then score = score + weight end
    end

    local padded = " " .. flat .. " "
    for phrase, weight in pairs(profile.detectPhrases or {}) do
        local foldedPhrase = DetectionText(phrase)
        if foldedPhrase ~= "" and padded:find(" " .. foldedPhrase .. " ", 1, true) then
            score = score + (tonumber(weight) or 0)
        end
    end

    return score
end

function PT.GetLanguageName(code)
    code = tostring(code or "UNKNOWN"):upper()
    return (PT.LanguageNames and PT.LanguageNames[code]) or code
end

function PT.DetectLanguage(message)
    if type(message) ~= "string" or Trim(message) == "" then
        return "UNKNOWN", 0
    end

    -- Any Cyrillic strongly indicates a Russian/local message. This prevents
    -- mixed RU+WoW jargon from being accidentally translated as English.
    if ContainsCyrillic(message) then
        return "RU", 100
    end

    local lowered = LowerForeign(message)
    local flat = DetectionText(message)
    if flat == "" then
        return "UNKNOWN", 0
    end

    local scores = {}
    for code, profile in pairs(PT.LanguageProfiles or {}) do
        scores[code] = ScoreProfile(flat, profile)
    end

    -- Character-level evidence. This is intentionally weighted, not absolute:
    -- accents are shared by several European languages.
    if lowered:find("ß", 1, true) or lowered:find("ä", 1, true)
        or lowered:find("ö", 1, true) or lowered:find("ü", 1, true) then
        AddLanguageScore(scores, "DE", 5)
    end

    if lowered:find("ñ", 1, true) or lowered:find("¿", 1, true)
        or lowered:find("¡", 1, true) then
        AddLanguageScore(scores, "ES", 6)
    end

    if lowered:find("ą", 1, true) or lowered:find("ć", 1, true)
        or lowered:find("ę", 1, true) or lowered:find("ł", 1, true)
        or lowered:find("ń", 1, true) or lowered:find("ś", 1, true)
        or lowered:find("ź", 1, true) or lowered:find("ż", 1, true) then
        AddLanguageScore(scores, "PL", 7)
    end

    if lowered:find("œ", 1, true) or lowered:find("ê", 1, true)
        or lowered:find("è", 1, true) or lowered:find("ë", 1, true)
        or lowered:find("ï", 1, true) then
        AddLanguageScore(scores, "FR", 4)
    end

    if lowered:find("ã", 1, true) or lowered:find("õ", 1, true) then
        AddLanguageScore(scores, "PT", 5)
    end

    -- Existing exact EN dictionaries are strong evidence for short WoW chat
    -- such as "gg", "omw", "need sum", "inv pls".
    local normalizedEN = Normalize(message)
    if PT.Exact and PT.Exact[normalizedEN] then
        AddLanguageScore(scores, "EN", 7)
    elseif PT.GeneralExact and PT.GeneralExact[normalizedEN] then
        AddLanguageScore(scores, "EN", 4)
    end

    local bestCode, bestScore = "UNKNOWN", 0
    local secondScore = 0
    for code, score in pairs(scores) do
        score = tonumber(score) or 0
        if score > bestScore then
            secondScore = bestScore
            bestScore = score
            bestCode = code
        elseif score > secondScore then
            secondScore = score
        end
    end

    -- Require enough evidence. One generic word is not enough to confidently
    -- label a message as a specific language.
    if bestScore < 3 then
        return "UNKNOWN", bestScore
    end

    -- Ties between Romance languages are common on very short messages.
    if bestScore == secondScore and bestScore < 6 then
        return "UNKNOWN", bestScore
    end

    return bestCode, bestScore
end

local function SplitForeignToken(token)
    local prefix = token:match("^([%p]*)") or ""
    local suffix = token:match("([%p]*)$") or ""

    local first = #prefix + 1
    local last = #token - #suffix
    if last < first then
        return "", token, ""
    end

    return prefix, token:sub(first, last), suffix
end

local function TranslateForeignToRU(message, languageCode)
    local profile = PT.LanguageProfiles and PT.LanguageProfiles[languageCode]
    if not profile or not profile.words then return nil end

    local verified = LookupVerifiedCorrection(
        tostring(languageCode or "") .. "_RU",
        LowerForeign(message),
        false
    )
    if verified then
        return verified, 200, "FULL"
    end

    local protectedMessage, links = ProtectHyperlinks(message)
    local translated = LowerForeign(protectedMessage)
    local hits = 0

    -- Long/common structures first. Tables are already ordered from specific
    -- to generic; unknown material around them remains untouched.
    for _, rule in ipairs(profile.phrases or {}) do
        local source = LowerForeign(rule[1] or "")
        local target = rule[2]
        if source ~= "" and type(target) == "string" and target ~= "" then
            local newText, changed = ReplaceWholePhrase(translated, source, target)
            if changed then
                translated = newText
                hits = hits + 3
            end
        end
    end

    local out = {}
    for token, spaces in translated:gmatch("(%S+)(%s*)") do
        if ContainsCyrillic(token) then
            out[#out + 1] = token .. spaces
        else
            local prefix, core, suffix = SplitForeignToken(token)
            local key = FoldLatin(core)
            local mapped = profile.words[key]
            if mapped then
                token = prefix .. mapped .. suffix
                hits = hits + 1
            end
            out[#out + 1] = token .. spaces
        end
    end

    translated = CleanSpacing(table.concat(out))
    translated = RestoreHyperlinks(translated, links)

    if hits == 0 or translated == "" then
        return nil
    end

    -- A successful partial translation is useful in party chat. Unlike RU→EN
    -- sending, we do NOT block it: untranslated source fragments stay visible.
    local status = translated:find("[A-Za-z]") and "PARTIAL" or "FULL"
    return translated, hits, status
end

function PT.TranslateIncoming(message)
    if type(message) ~= "string" or message == "" then
        return nil, 0, "UNKNOWN", "FAILED"
    end

    local languageCode, confidence = PT.DetectLanguage(message)

    if languageCode == "RU" then
        return nil, 0, "RU", "SOURCE_RU"
    end

    if languageCode == "EN" then
        local ru, hits = PT.Translate(message)
        if ru then
            return ru, hits or 0, "EN", "FULL", confidence
        end
        return nil, 0, "EN", "FAILED", confidence
    end

    if languageCode ~= "UNKNOWN" then
        local translated, hits, status = TranslateForeignToRU(message, languageCode)
        if translated then
            return translated, hits or 0, languageCode, status or "PARTIAL", confidence
        end
        return nil, 0, languageCode, "FAILED", confidence
    end

    -- Conservative fallback for ambiguous short ASCII WoW chat. Only accept
    -- English when the existing translator has multiple dictionary hits.
    local fallback, fallbackHits = PT.Translate(message)
    if fallback and (tonumber(fallbackHits) or 0) >= 2 then
        return fallback, fallbackHits, "EN", "FULL", confidence
    end

    return nil, 0, "UNKNOWN", "FAILED", confidence
end

local function SimplifyRUForIntent(text)
    local s = LowerRU(text or "")
    s = s:gsub("[,%.%!%?%;:%(%)]", " ")
    s = s:gsub("%s+", " ")
    return Trim(s)
end

-- High-priority practical RU -> EN chat phrases.
-- These intentionally override literal FreeDict-style translations.
local RU_LOCAL_PHRASES = {
    ["спасибо большое"] = "thank you very much",
    ["большое спасибо"] = "thank you very much",
    ["привет друзья"] = "hi friends",
    ["привет всем"] = "hi everyone",

    -- Russian pronoun/case forms frequently used as standalone chat fragments.
    ["все"] = "everyone",
    ["всё"] = "everything",
    ["всем"] = "everyone",
    ["всех"] = "everyone",
    ["всеми"] = "everyone",

    ["как у вас дела"] = "how are you",
    ["как дела"] = "how are you",
    ["что нового"] = "what's new",
    ["народ как у вас дела"] = "guys, how are you",
    ["ребята как у вас дела"] = "guys, how are you",
    ["давайте по быстрому пройдем"] = "let's get through this quickly",
    ["давайте по быстрому пройдём"] = "let's get through this quickly",
    ["давайте быстро пройдем"] = "let's get through this quickly",
    ["давайте быстро пройдём"] = "let's get through this quickly",

    -- Common WoW "finish the run / finish it" phrasing.
    ["давайте закроем до конца"] = "let's finish it",
    ["давайте закончим до конца"] = "let's finish it",
    ["давайте пройдем до конца"] = "let's finish the run",
    ["давайте пройдём до конца"] = "let's finish the run",
    ["закроем до конца"] = "finish it",
    ["закончим до конца"] = "finish it",
    ["пройдем до конца"] = "finish the run",
    ["пройдём до конца"] = "finish the run",
    ["до конца"] = "to the end",

    ["5 мин афк я курить"] = "afk 5 min, going for a smoke",
    ["афк 5 мин покурить"] = "afk 5 min, going for a smoke",

    ["поговорить со мной"] = "talk to me",
    ["говорить со мной"] = "talk to me",
    ["поговори со мной"] = "talk to me",
    ["поговорите со мной"] = "talk to me",
    ["со мной"] = "with me",
    ["с нами"] = "with us",
    ["помогите мне"] = "help me",
    ["помоги мне"] = "help me",
    ["можете помочь"] = "can you help",
    ["можешь помочь"] = "can you help",

    ["не пульте пока я иду"] = "don't pull yet, I'm coming",
    ["не пулите пока я иду"] = "don't pull yet, I'm coming",
    ["не пуль пока я иду"] = "don't pull yet, I'm coming",

    ["дайте бл на следующем пулле"] = "use lust on next pull",
    ["бл на следующем пулле"] = "use lust on next pull",

    ["танк можешь забрать босса"] = "tank, can you take the boss?",
    ["танк можешь забрать ада"] = "tank, can you take the add?",
    ["танк можешь забрать адда"] = "tank, can you take the add?",
    ["танк можешь забрать адов"] = "tank, can you take the adds?",
    ["танк можешь забрать аддов"] = "tank, can you take the adds?",
}

-- Word and form overrides for common real-chat Russian.
-- They are checked before the large general dictionary.
local RU_WORD_OVERRIDES = {
    ["друг"] = "friend",
    ["друзья"] = "friends",
    ["другу"] = "friend",
    ["друга"] = "friend",
    ["друзьям"] = "friends",
    ["народ"] = "guys",
    ["ребята"] = "guys",

    ["все"] = "everyone",
    ["всем"] = "everyone",
    ["всех"] = "everyone",
    ["всеми"] = "everyone",

    ["можешь"] = "can",
    ["можете"] = "can",
    ["могу"] = "can",

    ["поговорить"] = "talk",
    ["говорить"] = "talk",
    ["поговори"] = "talk",
    ["поговорите"] = "talk",

    ["помоги"] = "help",
    ["помогите"] = "help",

    ["сумануть"] = "summon",
    ["суммануть"] = "summon",
    ["сумонуть"] = "summon",
    ["суммонуть"] = "summon",
    ["суммоннуть"] = "summon",
    ["сумоннуть"] = "summon",
    ["суммонить"] = "summon",
    ["призвать"] = "summon",

    ["реснуть"] = "rez",
    ["реснуть"] = "rez",
    ["ресни"] = "rez",
    ["ресните"] = "rez",

    ["пульнуть"] = "pull",
    ["пулить"] = "pull",
    ["пуль"] = "pull",
    ["пулль"] = "pull",

    ["хилить"] = "heal",
    ["хильнуть"] = "heal",
    ["хиль"] = "heal",
    ["лечить"] = "heal",

    ["со"] = "with",
    ["мной"] = "me",
    ["нами"] = "us",

    ["большое"] = "much",
    ["очень"] = "very",

    ["забрать"] = "take",
    ["забери"] = "take",
    ["возьми"] = "take",
    ["взять"] = "take",

    ["закроем"] = "finish",
    ["закончим"] = "finish",
    ["пройдем"] = "finish",
    ["пройдём"] = "finish",
    ["конца"] = "end",

    ["следующем"] = "next",
    ["следующий"] = "next",
    ["пулле"] = "pull",
    ["пуле"] = "pull",
    ["пулла"] = "pull",
    ["пуллом"] = "pull",

    ["боссом"] = "boss",
    ["боссе"] = "boss",
    ["фазе"] = "phase",
    ["фазой"] = "phase",
    ["сек"] = "sec",
    ["секунд"] = "seconds",

    ["иду"] = "coming",
    ["идём"] = "coming",
    ["идем"] = "coming",
}

local RU_ACTION_ALIASES = {
    ["поговорить"] = "talk",
    ["говорить"] = "talk",
    ["поговори"] = "talk",
    ["поговорите"] = "talk",

    ["сумануть"] = "summon",
    ["суммануть"] = "summon",
    ["сумонуть"] = "summon",
    ["суммонуть"] = "summon",
    ["суммоннуть"] = "summon",
    ["сумоннуть"] = "summon",
    ["суммонить"] = "summon",
    ["призвать"] = "summon",

    ["реснуть"] = "rez",
    ["реснуть"] = "rez",
    ["ресни"] = "rez",
    ["ресните"] = "rez",

    ["пулить"] = "pull",
    ["пульнуть"] = "pull",
    ["пуль"] = "pull",
    ["пулль"] = "pull",

    ["хилить"] = "heal",
    ["хильнуть"] = "heal",
    ["хиль"] = "heal",
    ["лечить"] = "heal",

    ["сбить"] = "interrupt",
    ["сбей"] = "interrupt",
    ["сбейте"] = "interrupt",

    ["помочь"] = "help",

    ["забрать"] = "take",
    ["забери"] = "take",
    ["возьми"] = "take",
    ["взять"] = "take",
}

local RU_OBJECT_ALIASES = {
    ["меня"] = "me",
    ["нас"] = "us",

    ["танка"] = "the tank",
    ["танк"] = "the tank",
    ["хила"] = "the healer",
    ["хилера"] = "the healer",
    ["хил"] = "the healer",
    ["дд"] = "the dps",

    ["босса"] = "the boss",
    ["босс"] = "the boss",
    ["боссом"] = "the boss",
    ["боссе"] = "the boss",

    -- "add" is frequently typed with missing/doubled consonants in Russian WoW chat.
    ["ад"] = "the add",
    ["ада"] = "the add",
    ["адд"] = "the add",
    ["адда"] = "the add",
    ["ады"] = "the adds",
    ["адов"] = "the adds",
    ["адды"] = "the adds",
    ["аддов"] = "the adds",

    ["череп"] = "skull",
    ["черепа"] = "skull",
}

local function LookupRUWord(core)
    if not core or core == "" then return nil end

    local override = RU_WORD_OVERRIDES[core]
    if override then return override end

    local mapped = PT.RUWords and PT.RUWords[core]
    if mapped then return mapped end

    mapped = PT.GeneralRUWords and PT.GeneralRUWords[core]
    if mapped then return mapped end

    -- Conservative morphology fallback for a few common Russian forms.
    -- Only accept a generated candidate when it actually exists in our dictionary.
    local candidates = {}

    local function addCandidate(value)
        if value and value ~= "" and value ~= core then
            candidates[#candidates + 1] = value
        end
    end

    if core:sub(-4) == "ите" then
        addCandidate(core:sub(1, -5) .. "ить")
    end
    if core:sub(-4) == "ого" then
        addCandidate(core:sub(1, -5) .. "ый")
    end
    if core:sub(-4) == "ому" then
        addCandidate(core:sub(1, -5) .. "ый")
    end
    if core:sub(-3) == "ое" then
        addCandidate(core:sub(1, -4) .. "ой")
    end
    if core:sub(-3) == "ая" then
        addCandidate(core:sub(1, -4) .. "ый")
    end
    if core:sub(-3) == "ые" then
        addCandidate(core:sub(1, -4) .. "ый")
    end

    for _, candidate in ipairs(candidates) do
        local value = RU_WORD_OVERRIDES[candidate]
            or (PT.RUWords and PT.RUWords[candidate])
            or (PT.GeneralRUWords and PT.GeneralRUWords[candidate])
        if value then return value end
    end

    return nil
end

local function TranslateRUActionPhrase(phrase)
    local s = SimplifyRUForIntent(phrase)
    if s == "" then return nil end

    local direct = RU_LOCAL_PHRASES[s]
    if direct then return direct end

    -- Context-sensitive "talk to me/us".
    if s == "поговорить со мной" or s == "говорить со мной"
       or s == "поговори со мной" or s == "поговорите со мной" then
        return "talk to me"
    end
    if s == "поговорить с нами" or s == "говорить с нами"
       or s == "поговори с нами" or s == "поговорите с нами" then
        return "talk to us"
    end

    local first, rest = s:match("^([^%s]+)%s*(.*)$")
    local action = first and RU_ACTION_ALIASES[first] or nil
    if not action then return nil end

    if rest == "" then
        return action
    end

    if action == "talk" and rest == "со мной" then
        return "talk to me"
    end
    if action == "talk" and rest == "с нами" then
        return "talk to us"
    end

    local object = RU_OBJECT_ALIASES[rest]
    if object then
        return action .. " " .. object
    end

    -- Support a simple two-word object tail.
    local objectFirst, objectRest = rest:match("^([^%s]+)%s+(.+)$")
    if objectFirst and objectRest then
        local firstMapped = RU_OBJECT_ALIASES[objectFirst] or LookupRUWord(objectFirst)
        local restMapped = RU_OBJECT_ALIASES[objectRest] or LookupRUWord(objectRest)
        if firstMapped and restMapped then
            return action .. " " .. firstMapped .. " " .. restMapped
        end
    end

    local mappedRest = LookupRUWord(rest)
    if mappedRest then
        return action .. " " .. mappedRest
    end

    return nil
end


local RU_CONTEXT_PHRASES = {
    ["сейчас"] = "now",
    ["до конца"] = "to the end",
    ["после пулла"] = "after pull",
    ["перед пуллом"] = "before pull",
    ["на следующем пулле"] = "on next pull",
    ["на следующем пуле"] = "on next pull",
    ["во второй фазе"] = "in phase two",
    ["в второй фазе"] = "in phase two",
    ["после каста"] = "after cast",
    ["перед боссом"] = "before boss",
    ["после механики"] = "after mechanic",
    ["во время бл"] = "during lust",
    ["во время блудласта"] = "during lust",
    ["перед следующим паком"] = "before next pack",
    ["во время энрейджа"] = "during enrage",
    ["на следующем боссе"] = "on next boss",
    ["на следующем боссе"] = "on next boss",
    ["после аддов"] = "after adds",
    ["перед второй фазой"] = "before phase two",
}

local RU_ROLE_TO_EN = {
    ["танк"] = "tank",
    ["хил"] = "healer",
    ["хилер"] = "healer",
    ["дд"] = "dps",
}

local RU_COMBAT_ACTIONS = {
    ["забери"] = "take",
    ["забрать"] = "take",
    ["возьми"] = "take",
    ["пуль"] = "pull",
    ["пулль"] = "pull",
    ["фокус"] = "focus",
    ["убей"] = "kill",
    ["убейте"] = "kill",
    ["застань"] = "stun",
    ["застаньте"] = "stun",
    ["замедли"] = "slow",
    ["замедлите"] = "slow",
    ["ресни"] = "rez",
    ["ресните"] = "rez",
    ["призови"] = "summon",
    ["призовите"] = "summon",
    ["хиль"] = "heal",
    ["хильте"] = "heal",
}

local function TranslateRUContext(text)
    local s = SimplifyRUForIntent(text)
    if s == "" then return "" end

    local direct = RU_CONTEXT_PHRASES[s]
    if direct then return direct end

    local number = s:match("^через%s+(%d+)%s+сек$")
    if number then
        return "in " .. number .. " sec"
    end

    number = s:match("^через%s+(%d+)%s+секунд$")
    if number then
        return "in " .. number .. " seconds"
    end

    return nil
end

local function TranslateRUObjectWithContext(text)
    local s = SimplifyRUForIntent(text)
    if s == "" then return nil, nil end

    -- Try longest known context suffix first.
    local contexts = {
        "перед следующим паком",
        "до конца",
        "сейчас",
        "на следующем пулле",
        "на следующем пуле",
        "перед второй фазой",
        "во второй фазе",
        "в второй фазе",
        "после механики",
        "во время энрейджа",
        "во время блудласта",
        "во время бл",
        "перед боссом",
        "после пулла",
        "после каста",
        "после аддов",
        "на следующем боссе",
    }

    for _, contextRU in ipairs(contexts) do
        if s:sub(-#contextRU) == contextRU then
            local objectRU = Trim(s:sub(1, #s - #contextRU))
            local objectEN = RU_OBJECT_ALIASES[objectRU]
            local contextEN = RU_CONTEXT_PHRASES[contextRU]
            if objectEN and contextEN then
                return objectEN, contextEN
            end
        end
    end

    return RU_OBJECT_ALIASES[s], nil
end

local function TranslateRUSimpleClause(text)
    local s = SimplifyRUForIntent(text)
    if s == "" then return nil end

    local direct = RU_LOCAL_PHRASES[s]
    if direct then return direct end

    -- Timers.
    local number = s:match("^пуль%s+через%s+(%d+)%s+сек$")
    if number then return "pull in " .. number .. " sec" end

    number = s:match("^подождите%s+(%d+)%s+сек$")
    if number then return "wait " .. number .. " sec" end

    number = s:match("^бл%s+через%s+(%d+)%s+сек$")
    if number then return "lust in " .. number .. " sec" end

    number = s:match("^адды%s+через%s+(%d+)%s+сек$")
    if number then return "adds in " .. number .. " sec" end

    number = s:match("^готов%s+через%s+(%d+)%s+сек$")
    if number then return "ready in " .. number .. " sec" end

    number = s:match("^отош[её]л%s+на%s+(%d+)%s+мин$")
    if number then return "brb " .. number .. " min" end

    -- Common standalone raid calls.
    local standalone = {
        ["стакайтесь"] = "stack",
        ["разойдитесь"] = "spread",
        ["выйдите"] = "move out",
        ["выйди"] = "move out",
        ["подождите"] = "wait",
        ["подожди"] = "wait",
        ["пуль"] = "pull",
        ["фокус"] = "focus",
        ["убейте"] = "kill",
        ["убей"] = "kill",
        ["используйте сейвы"] = "use defensives",
        ["дайте бл"] = "use lust",
        ["дайте блудласт"] = "use lust",
        ["хиль танка"] = "heal tank",
        ["хильте танка"] = "heal tank",
        ["ресни хила"] = "rez healer",
        ["ресните хила"] = "rez healer",
        ["выйдите из огня"] = "move out of fire",
        ["сбей следующий каст"] = "interrupt next cast",
        ["сбейте следующий каст"] = "interrupt next cast",
    }
    if standalone[s] then return standalone[s] end

    -- Combat action + object + optional context.
    local actionRU, rest = s:match("^([^%s]+)%s+(.+)$")
    local actionEN = actionRU and RU_COMBAT_ACTIONS[actionRU] or nil
    if actionEN and rest then
        local objectEN, contextEN = TranslateRUObjectWithContext(rest)
        if objectEN then
            local result = actionEN .. " " .. objectEN
            if contextEN and contextEN ~= "" then
                result = result .. " " .. contextEN
            end
            return result
        end
    end

    -- Phrase-level fallbacks already known to the reverse dictionaries.
    local mapped = (PT.RUExact and PT.RUExact[s])
        or (PT.GeneralRUExact and PT.GeneralRUExact[s])
    if mapped and not ContainsCyrillic(mapped) then
        return mapped
    end

    return nil
end



-- ============================================================================
-- RU -> EN semantic layer
--
-- Surface forms are normalized into WoW concepts first. Only after that do we
-- build an English sentence. The large general dictionary is fallback only.
-- ============================================================================

local RU_SEMANTIC_FORMS = {
    ["давай"] = "LET_US",
    ["давайте"] = "LET_US",

    ["пожалуйста"] = "POLITE",
    ["плиз"] = "POLITE",
    ["плз"] = "POLITE",
    ["пж"] = "POLITE",

    ["можешь"] = "CAN_YOU",
    ["можете"] = "CAN_YOU",

    ["все"] = "EVERYONE",
    ["всем"] = "EVERYONE",
    ["всех"] = "EVERYONE",
    ["всеми"] = "EVERYONE",

    ["быстро"] = "FAST",
    ["побыстрее"] = "FAST",
    ["быстрее"] = "FAST",
    ["быстренько"] = "FAST",

    ["уже"] = "FILLER",
    ["просто"] = "FILLER",
    ["свой"] = "FILLER",
    ["свою"] = "FILLER",
    ["свои"] = "FILLER",
    ["своего"] = "FILLER",
    ["ваш"] = "FILLER",
    ["вашу"] = "FILLER",
    ["ваши"] = "FILLER",
    ["вашего"] = "FILLER",

    -- SHOW
    ["показать"] = "SHOW",
    ["покажи"] = "SHOW",
    ["покажите"] = "SHOW",
    ["покажем"] = "SHOW",
    ["показывай"] = "SHOW",
    ["показывайте"] = "SHOW",

    -- COMPLETE
    ["пройти"] = "COMPLETE",
    ["пройдем"] = "COMPLETE",
    ["пройдём"] = "COMPLETE",
    ["пройдите"] = "COMPLETE",
    ["проходим"] = "COMPLETE",
    ["проходить"] = "COMPLETE",

    ["закрыть"] = "COMPLETE",
    ["закроем"] = "COMPLETE",
    ["закрой"] = "COMPLETE",
    ["закройте"] = "COMPLETE",

    ["закончить"] = "COMPLETE",
    ["закончим"] = "COMPLETE",
    ["закончите"] = "COMPLETE",
    ["заканчиваем"] = "COMPLETE",

    ["начать"] = "START",
    ["начнем"] = "START",
    ["начнём"] = "START",
    ["начинаем"] = "START",

    ["пойти"] = "GO",
    ["пойдем"] = "GO",
    ["пойдём"] = "GO",
    ["идем"] = "GO",
    ["идём"] = "GO",

    -- Objects
    ["класс"] = "CLASS",
    ["класса"] = "CLASS",
    ["классу"] = "CLASS",
    ["классом"] = "CLASS",
    ["классе"] = "CLASS",
    ["классы"] = "CLASS",
    ["классов"] = "CLASS",

    ["ключ"] = "KEY",
    ["ключа"] = "KEY",
    ["ключу"] = "KEY",
    ["ключом"] = "KEY",
    ["ключе"] = "KEY",
    ["ключи"] = "KEY",

    ["маршрут"] = "ROUTE",
    ["маршрута"] = "ROUTE",
    ["маршруту"] = "ROUTE",
    ["маршрутом"] = "ROUTE",
    ["маршруте"] = "ROUTE",

    ["илвл"] = "ITEM_LEVEL",
    ["айлвл"] = "ITEM_LEVEL",
    ["ивл"] = "ITEM_LEVEL",
    ["гирскор"] = "ITEM_LEVEL",

    ["предмет"] = "ITEM",
    ["предмета"] = "ITEM",
    ["предмету"] = "ITEM",
    ["предметом"] = "ITEM",
    ["предмете"] = "ITEM",

    ["спек"] = "SPEC",
    ["спека"] = "SPEC",
    ["спеку"] = "SPEC",
    ["спеком"] = "SPEC",

    ["роль"] = "ROLE",
    ["роли"] = "ROLE",
    ["ролью"] = "ROLE",

    ["таланты"] = "TALENTS",
    ["талантов"] = "TALENTS",

    ["билд"] = "BUILD",
    ["билда"] = "BUILD",
    ["билду"] = "BUILD",
    ["билдом"] = "BUILD",

    ["шмот"] = "GEAR",
    ["гир"] = "GEAR",
    ["экипировку"] = "GEAR",
    ["экипировка"] = "GEAR",

    ["ран"] = "RUN",
    ["рана"] = "RUN",
    ["прохождение"] = "RUN",

    ["данж"] = "DUNGEON",
    ["данжа"] = "DUNGEON",
    ["подземелье"] = "DUNGEON",
    ["подземелья"] = "DUNGEON",

    ["это"] = "IT",
    ["его"] = "IT",

    ["доконца"] = "TO_END",

    -- AFK / short status chat.
    ["афк"] = "AFK",
    ["мин"] = "MINUTE_UNIT",
    ["минута"] = "MINUTE_UNIT",
    ["минуту"] = "MINUTE_UNIT",
    ["минуты"] = "MINUTE_UNIT",
    ["минут"] = "MINUTE_UNIT",

    ["курить"] = "SMOKE",
    ["покурить"] = "SMOKE",
    ["курю"] = "SMOKE",
    ["покурю"] = "SMOKE",

    ["я"] = "STATUS_FILLER",
    ["на"] = "STATUS_FILLER",
    ["пойду"] = "STATUS_FILLER",
    ["пошел"] = "STATUS_FILLER",
    ["пошёл"] = "STATUS_FILLER",
}

local RU_SHOW_OBJECT_EN = {
    CLASS = "your class",
    KEY = "your key",
    ROUTE = "the route",
    ITEM_LEVEL = "your item level",
    ITEM = "the item",
    SPEC = "your spec",
    ROLE = "your role",
    TALENTS = "your talents",
    BUILD = "your build",
    GEAR = "your gear",
}

local RU_COMPLETE_OBJECT_EN = {
    KEY = "the key",
    RUN = "the run",
    DUNGEON = "the dungeon",
    IT = "it",
}

local function NormalizeRUSemanticText(text)
    local s = SimplifyRUForIntent(text)

    s = s:gsub("по%-быстрому", "побыстрее")
    s = s:gsub("по%s+быстрому", "побыстрее")
    s = s:gsub("по%s+быстрее", "побыстрее")

    s = s:gsub("уровень%s+предметов", "илвл")
    s = s:gsub("уровень%s+шмота", "илвл")

    s = s:gsub("до%s+конца", "доконца")

    s = s:gsub("%s+", " ")
    return Trim(s)
end

local function BuildRUSemanticFrame(text)
    local s = NormalizeRUSemanticText(text)
    if s == "" then return nil end

    local frame = {
        action = nil,
        object = nil,
        lets = false,
        polite = false,
        canYou = false,
        fast = false,
        toEnd = false,
        recognized = 0,
        unknown = 0,
        tokens = 0,
    }

    for token in s:gmatch("%S+") do
        frame.tokens = frame.tokens + 1

        local concept = RU_SEMANTIC_FORMS[token]
        if concept then
            frame.recognized = frame.recognized + 1

            if concept == "LET_US" then
                frame.lets = true
            elseif concept == "POLITE" then
                frame.polite = true
            elseif concept == "CAN_YOU" then
                frame.canYou = true
            elseif concept == "FAST" then
                frame.fast = true
            elseif concept == "TO_END" then
                frame.toEnd = true
            elseif concept == "FILLER" then
                -- Intentionally ignored.
            elseif concept == "SHOW"
                or concept == "COMPLETE"
                or concept == "START"
                or concept == "GO" then
                if not frame.action then
                    frame.action = concept
                end
            elseif not frame.object then
                frame.object = concept
            end
        else
            frame.unknown = frame.unknown + 1
        end
    end

    -- Semantic standalone objects/pronouns are allowed for a small,
    -- explicitly supported set such as EVERYONE.
    if not frame.action and frame.object ~= "EVERYONE" then return nil end

    -- Prevent the semantic layer from swallowing arbitrary free-form Russian.
    if frame.unknown > 0 then return nil end

    return frame
end

local function GenerateEnglishFromRUFrame(frame)
    if not frame then return nil end

    -- Standalone semantic pronoun fragments.
    if not frame.action and frame.object == "EVERYONE" then
        return "everyone"
    end
    if not frame.action then return nil end

    if frame.action == "SHOW" then
        local objectEN = frame.object and RU_SHOW_OBJECT_EN[frame.object] or nil
        if not objectEN then return nil end

        if frame.canYou then
            return "can you show " .. objectEN .. "?"
        end
        if frame.lets then
            return "let's show " .. objectEN
        end
        if frame.polite then
            return "please show " .. objectEN
        end
        return "show " .. objectEN
    end

    if frame.action == "COMPLETE" then
        local objectEN = frame.object and RU_COMPLETE_OBJECT_EN[frame.object] or nil

        -- English "finish" already expresses Russian "до конца".
        if frame.lets then
            if objectEN then
                local result = "let's finish " .. objectEN
                if frame.fast then result = result .. " quickly" end
                return result
            end

            if frame.fast then
                return "let's get through this quickly"
            end
            return "let's finish it"
        end

        if frame.canYou then
            local target = objectEN or "it"
            local result = "can you finish " .. target
            if frame.fast then result = result .. " quickly" end
            return result .. "?"
        end

        local target = objectEN or "it"
        local result = "finish " .. target
        if frame.fast then result = result .. " quickly" end
        return result
    end

    if frame.action == "START" then
        if frame.lets then return "let's start" end
        if frame.canYou then return "can you start?" end
        return "start"
    end

    if frame.action == "GO" then
        if frame.lets then return "let's go" end
        if frame.canYou then return "can you go?" end
        return "go"
    end

    return nil
end


local function TranslateRUStatusIntent(text)
    local s = NormalizeRUSemanticText(text)
    if s == "" then return nil end

    local hasAFK = false
    local hasMinuteUnit = false
    local hasSmoke = false
    local duration = nil
    local unknown = 0

    for token in s:gmatch("%S+") do
        if token:match("^%d+$") then
            if not duration then
                duration = tonumber(token)
            else
                unknown = unknown + 1
            end
        else
            local concept = RU_SEMANTIC_FORMS[token]

            if concept == "AFK" then
                hasAFK = true
            elseif concept == "MINUTE_UNIT" then
                hasMinuteUnit = true
            elseif concept == "SMOKE" then
                hasSmoke = true
            elseif concept == "STATUS_FILLER" then
                -- Russian conversational glue: "я", "на", "пойду", etc.
            else
                unknown = unknown + 1
            end
        end
    end

    -- Do not consume arbitrary sentences just because they contain "афк".
    if unknown > 0 then return nil end
    if not hasAFK then return nil end

    local result = "afk"

    if duration and hasMinuteUnit then
        result = result .. " " .. tostring(duration) .. " min"
    end

    if hasSmoke then
        result = result .. ", going for a smoke"
    end

    return result, 40
end

local function TranslateRUSemanticIntent(text)
    -- Status/AFK phrases have their own unordered semantic frame.
    local status, statusHits = TranslateRUStatusIntent(text)
    if status then
        return status, statusHits or 40
    end

    local frame = BuildRUSemanticFrame(text)
    if not frame then return nil end

    local translated = GenerateEnglishFromRUFrame(frame)
    if not translated then return nil end

    return translated, 30
end

local function TranslateRUConversationClause(clause)
    local s = SimplifyRUForIntent(clause)
    if s == "" then return nil end

    local direct = RU_LOCAL_PHRASES[s]
    if direct then return direct end

    -- Common greetings / casual chat.
    if s == "народ" or s == "ребята" then
        return "guys"
    end
    if s == "как у вас дела" or s == "как дела" then
        return "how are you"
    end
    if s == "что нового" then
        return "what's new"
    end

    local tail = s:match("^народ%s+(.+)$") or s:match("^ребята%s+(.+)$")
    if tail then
        local translatedTail = TranslateRUConversationClause(tail)
        if translatedTail then
            return "guys, " .. translatedTail
        end
    end

    -- "давайте ..." constructions.
    local afterLets = s:match("^давайте%s+(.+)$")
    if afterLets then
        if afterLets == "по быстрому пройдем"
           or afterLets == "по быстрому пройдём"
           or afterLets == "быстро пройдем"
           or afterLets == "быстро пройдём" then
            return "let's get through this quickly"
        end

        -- In Russian WoW chat "закрыть до конца" usually means completing
        -- the dungeon/key/run, not literally "close until the end".
        if afterLets == "закроем до конца"
           or afterLets == "закончили до конца"
           or afterLets == "закончим до конца" then
            return "let's finish it"
        end

        if afterLets == "пройдем до конца"
           or afterLets == "пройдём до конца" then
            return "let's finish the run"
        end

        if afterLets == "закроем" or afterLets == "закончим" then
            return "let's finish it"
        end

        if afterLets == "начнем" or afterLets == "начнём" then
            return "let's start"
        end
        if afterLets == "пойдем" or afterLets == "пойдём" then
            return "let's go"
        end
    end

    return nil
end

local function TranslateRUMultiSentence(message)
    if type(message) ~= "string" or message == "" then return nil end

    -- Split on sentence punctuation but preserve it in the output.
    local parts = {}
    local startPos = 1
    local length = #message

    for i = 1, length do
        local ch = message:sub(i, i)
        if ch == "?" or ch == "!" or ch == "." then
            local raw = Trim(message:sub(startPos, i - 1))
            if raw ~= "" then
                parts[#parts + 1] = { text = raw, punct = ch }
            end
            startPos = i + 1
        end
    end

    local tail = Trim(message:sub(startPos))
    if tail ~= "" then
        parts[#parts + 1] = { text = tail, punct = "" }
    end

    if #parts < 2 then return nil end

    local translated = {}
    for _, part in ipairs(parts) do
        local value = TranslateRUSemanticIntent(part.text)
        if not value then
            value = TranslateRUConversationClause(part.text)
        end
        if not value then
            -- Try the normal intent/clause machinery for each sentence.
            value = TranslateRUSimpleClause(part.text)
        end
        if not value then
            return nil
        end

        -- Avoid duplicated punctuation from canned phrases.
        value = value:gsub("[%.%!%?]+$", "")
        translated[#translated + 1] = value .. (part.punct or "")
    end

    return table.concat(translated, " ")
end

local function TranslateRUIntent(message)
    local original = LowerRU(message or "")
    local simple = SimplifyRUForIntent(original)
    if simple == "" then return nil end

    local direct = RU_LOCAL_PHRASES[simple]
    if direct then return direct, 10 end

    local semantic, semanticHits = TranslateRUSemanticIntent(simple)
    if semantic then return semantic, semanticHits or 30 end

    local conversation = TranslateRUConversationClause(simple)
    if conversation then return conversation, 10 end

    -- Natural chat phrases seen in the stress-test.
    local natural = {
        ["не пульте пока я бегу"] = "don't pull yet, I'm coming",
        ["не пулите пока я бегу"] = "don't pull yet, I'm coming",
        ["дайте бл на следующем боссе"] = "use lust on next boss",
        ["привет всем как дела"] = "hi everyone, how are you?",
        ["хилу нужно попить"] = "healer needs to drink",
        ["нам нужен еще один дд"] = "we need one more dps",
        ["нам нужен ещё один дд"] = "we need one more dps",
    }
    if natural[simple] then
        return natural[simple], 12
    end

    -- Timers are common enough to parse directly.
    local timer = TranslateRUSimpleClause(simple)
    if timer and (
        simple:match("через%s+%d+%s+сек$")
        or simple:match("^подождите%s+%d+%s+сек$")
        or simple:match("^отош[её]л%s+на%s+%d+%s+мин$")
    ) then
        return timer, 12
    end

    -- Sequence grammar:
    -- "сначала X потом Y", "X потом Y", "X и потом Y", "X после Y".
    local left, right = simple:match("^сначала%s+(.+)%s+потом%s+(.+)$")
    if left and right then
        local a = TranslateRUSimpleClause(left)
        local b = TranslateRUSimpleClause(right)
        if a and b then
            return "first " .. a .. ", then " .. b, 15
        end
    end

    left, right = simple:match("^(.+)%s+и%s+потом%s+(.+)$")
    if left and right then
        local a = TranslateRUSimpleClause(left)
        local b = TranslateRUSimpleClause(right)
        if a and b then
            return a .. ", then " .. b, 15
        end
    end

    left, right = simple:match("^(.+)%s+потом%s+(.+)$")
    if left and right then
        local a = TranslateRUSimpleClause(left)
        local b = TranslateRUSimpleClause(right)
        if a and b then
            return a .. ", then " .. b, 15
        end
    end

    left, right = simple:match("^(.+)%s+после%s+(.+)$")
    if left and right then
        local a = TranslateRUSimpleClause(left)
        local b = TranslateRUSimpleClause(right)
        if a and b then
            return a .. " after " .. b, 14
        end
    end

    -- Simple conditions generated by real raid chat:
    -- "если танк умрет фокус босса"
    local roleRU, command = simple:match("^если%s+(танк|хил|хилер|дд)%s+умрет%s+(.+)$")
    if not roleRU then
        roleRU, command = simple:match("^если%s+(танк|хил|хилер|дд)%s+умрёт%s+(.+)$")
    end
    if roleRU and command then
        local translatedCommand = TranslateRUSimpleClause(command)
        if translatedCommand then
            return "if " .. RU_ROLE_TO_EN[roleRU] .. " dies, " .. translatedCommand, 15
        end
    end

    -- "когда танк будет готов пуль босса"
    roleRU, command = simple:match("^когда%s+(танк|хил|хилер|дд)%s+будет%s+готов%s+(.+)$")
    if roleRU and command then
        local translatedCommand = TranslateRUSimpleClause(command)
        if translatedCommand then
            return "when " .. RU_ROLE_TO_EN[roleRU] .. " is ready, " .. translatedCommand, 15
        end
    end

    -- Role request + action + target + optional time context.
    -- "танк можешь забрать босса после пулла"
    local roleWord, actionWord, requestRest =
        simple:match("^(танк|хил|хилер|дд)%s+можешь%s+([^%s]+)%s+(.+)$")
    if roleWord and actionWord and requestRest then
        local actionMap = {
            ["забрать"] = "take",
            ["реснуть"] = "rez",
            ["реснуть"] = "rez",
            ["призвать"] = "summon",
            ["сумануть"] = "summon",
            ["суммоннуть"] = "summon",
            ["хилить"] = "heal",
        }
        local actionEN = actionMap[actionWord]
        if actionEN then
            local objectEN, contextEN = TranslateRUObjectWithContext(requestRest)
            if objectEN then
                local result = RU_ROLE_TO_EN[roleWord] .. ", can you "
                    .. actionEN .. " " .. objectEN
                if contextEN and contextEN ~= "" then
                    result = result .. " " .. contextEN
                end
                return result .. "?", 15
            end
        end
    end

    -- General combat clause with optional context:
    -- "забери адда после пулла", "пуль босса во второй фазе", etc.
    local combat = TranslateRUSimpleClause(simple)
    if combat and (
        simple:match("^[^%s]+%s+босс")
        or simple:match("^[^%s]+%s+ад")
        or simple:match("^[^%s]+%s+череп")
    ) then
        return combat, 12
    end

    -- Real-chat greeting + request:
    -- "привет друзья, можете поговорить со мной"
    local tail = simple:match("^привет%s+друзья%s+(.+)$")
    if tail then
        local translatedTail = TranslateRUIntent(tail)
        if translatedTail then
            return "hi friends, " .. translatedTail, 12
        end
    end

    tail = simple:match("^привет%s+всем%s+(.+)$")
    if tail then
        local translatedTail = TranslateRUIntent(tail)
        if translatedTail then
            return "hi everyone, " .. translatedTail, 12
        end
    end

    -- "не пульте/не пулите пока я иду" and close real-chat forms.
    if simple == "не пульте пока я иду"
       or simple == "не пулите пока я иду"
       or simple == "не пуль пока я иду"
       or simple == "не пулльте пока я иду" then
        return "don't pull yet, I'm coming", 12
    end

    if simple == "не пульте пока мы идем"
       or simple == "не пульте пока мы идём"
       or simple == "не пулите пока мы идем"
       or simple == "не пулите пока мы идём" then
        return "don't pull yet, we're coming", 12
    end

    -- "дайте БЛ на следующем пулле" and close variants.
    if simple == "дайте бл на следующем пулле"
       or simple == "дайте бл на следующем пуле"
       or simple == "бл на следующем пулле"
       or simple == "бл на следующем пуле"
       or simple == "бл на следующий пулл" then
        return "use lust on next pull", 12
    end

    if simple == "дайте героизм на следующем пулле"
       or simple == "героизм на следующем пулле" then
        return "use heroism on next pull", 12
    end

    -- "{role} можешь забрать {target}".
    local roleWord, targetWord = simple:match("^(танк|хил|хилер|дд)%s+можешь%s+забрать%s+(.+)$")
    if roleWord and targetWord then
        local roleEN = {
            ["танк"] = "tank",
            ["хил"] = "healer",
            ["хилер"] = "healer",
            ["дд"] = "dps",
        }
        local targetEN = RU_OBJECT_ALIASES[targetWord]
        if targetEN then
            return roleEN[roleWord] .. ", can you take " .. targetEN .. "?", 12
        end
    end

    -- Same construction without explicit role.
    local takeTarget = simple:match("^можешь%s+забрать%s+(.+)$")
        or simple:match("^можете%s+забрать%s+(.+)$")
    if takeTarget then
        local targetEN = RU_OBJECT_ALIASES[takeTarget]
        if targetEN then
            return "can you take " .. targetEN .. "?", 10
        end
    end

    -- "можете/можешь поговорить со мной" -> "can you talk to me?"
    local request = simple:match("^можете%s+(.+)$") or simple:match("^можешь%s+(.+)$")
    if request then
        local action = TranslateRUActionPhrase(request)
        if action then
            return "can you " .. action .. "?", 10
        end
    end

    -- "кто может ..." -> "who can ...?"
    request = simple:match("^кто%s+может%s+(.+)$")
    if request then
        local action = TranslateRUActionPhrase(request)
        if action then
            return "who can " .. action .. "?", 10
        end
    end

    -- "помогите/помоги сумануть танка" -> "help summon the tank"
    request = simple:match("^помогите%s+(.+)$") or simple:match("^помоги%s+(.+)$")
    if request then
        local action = TranslateRUActionPhrase(request)
        if action then
            return "help " .. action, 10
        end
    end

    -- Polite action request. First try the grammar-aware combat parser so
    -- "пожалуйста забери адда после пулла" keeps both target case and context.
    request = simple:match("^пожалуйста%s+(.+)$")
        or simple:match("^плиз%s+(.+)$")
    if request then
        local clause = TranslateRUSimpleClause(request)
        if clause then
            return "please " .. clause, 12
        end

        local action = TranslateRUActionPhrase(request)
        if action then
            return "please " .. action, 8
        end
    end

    return nil
end

local function SplitRUToken(token)
    local prefix = token:match("^([%p]+)") or ""
    local suffix = token:match("([%p]+)$") or ""
    local coreStart = #prefix + 1
    local coreEnd = #token - #suffix
    local core = token
    if coreEnd >= coreStart then
        core = token:sub(coreStart, coreEnd)
    end
    return prefix, core, suffix
end

local function TranslateRULongestPhrases(text)
    local tokens = {}
    for token in text:gmatch("%S+") do
        local prefix, core, suffix = SplitRUToken(token)
        tokens[#tokens + 1] = {
            raw = token,
            prefix = prefix,
            core = core,
            suffix = suffix,
        }
    end

    if #tokens < 2 then return text, 0 end

    local out = {}
    local hits = 0
    local i = 1
    local MAX_WORDS = 6

    while i <= #tokens do
        local matched = false
        local maxLen = math.min(MAX_WORDS, #tokens - i + 1)

        for len = maxLen, 2, -1 do
            local parts = {}
            local valid = true
            for j = i, i + len - 1 do
                local core = tokens[j].core
                if not core or core == "" then
                    valid = false
                    break
                end
                parts[#parts + 1] = core
            end

            if valid then
                local phrase = LowerRU(table.concat(parts, " "))
                local mapped = RU_LOCAL_PHRASES[phrase]
                    or (PT.RUExact and PT.RUExact[phrase])
                    or (PT.GeneralRUExact and PT.GeneralRUExact[phrase])

                -- A RU -> EN phrase must actually produce non-Cyrillic output.
                if mapped and mapped ~= "" and not ContainsCyrillic(mapped) then
                    local first = tokens[i]
                    local last = tokens[i + len - 1]
                    out[#out + 1] = (first.prefix or "") .. mapped .. (last.suffix or "")
                    hits = hits + 3
                    i = i + len
                    matched = true
                    break
                end
            end
        end

        if not matched then
            out[#out + 1] = tokens[i].raw
            i = i + 1
        end
    end

    return CleanEnglishSpacing(table.concat(out, " ")), hits
end

local function FinalizeRUTranslation(translated, hits)
    translated = CleanEnglishSpacing(translated or "")

    if translated == "" or (hits or 0) == 0 then
        PT.LastRUTranslationStatus = "FAILED"
        PT.LastRUPartialTranslation = nil
        return nil, hits or 0, "FAILED"
    end

    if ContainsCyrillic(translated) then
        PT.LastRUTranslationStatus = "PARTIAL"
        PT.LastRUPartialTranslation = translated
        return nil, hits or 0, "PARTIAL", translated
    end

    PT.LastRUTranslationStatus = "FULL"
    PT.LastRUPartialTranslation = nil
    return translated, hits or 0, "FULL", translated
end

function PT.TranslateRU(message)
    if type(message) ~= "string" or message == "" then return nil end
    if not ContainsCyrillic(message) then return nil end
    BuildReverseDictionary()

    local normalized = LowerRU(message)

    -- Companion-verified exact corrections are the highest-priority layer.
    local verified = LookupVerifiedCorrection("RU_EN", message, true)
    if verified then
        return FinalizeRUTranslation(verified, 200)
    end

    -- 1. Hand-curated/trusted exact dictionary.
    local exact = PT.RUExact and PT.RUExact[normalized]
    if exact then
        return FinalizeRUTranslation(exact, 100)
    end

    -- 2. Semantic parser: morphology/forms -> concepts -> intent -> generator.
    -- It deliberately runs BEFORE the large ordinary dictionary.
    local semantic, semanticHits = TranslateRUSemanticIntent(normalized)
    if semantic then
        return FinalizeRUTranslation(semantic, semanticHits or 30)
    end

    -- 3. Multi-sentence conversational parser.
    local multi = TranslateRUMultiSentence(normalized)
    if multi then
        return FinalizeRUTranslation(multi, 20)
    end

    -- 4. Legacy/contextual WoW intent parser.
    local intent, intentHits = TranslateRUIntent(normalized)
    if intent then
        return FinalizeRUTranslation(intent, intentHits or 10)
    end

    -- 5. Large ordinary exact dictionary is fallback only.
    local generalExact = PT.GeneralRUExact and PT.GeneralRUExact[normalized]
    if generalExact and not ContainsCyrillic(generalExact) then
        return FinalizeRUTranslation(generalExact, 80)
    end

    -- 6. Longest known phrase matching: 6 words -> 2 words.
    local translated, phraseHits = TranslateRULongestPhrases(normalized)
    local hits = phraseHits or 0

    -- 7. Existing curated RU phrase rules.
    for _, rule in ipairs(PT.RUPhrases or {}) do
        local newText, changed = ReplaceWholeRUPhrase(translated, rule[1], rule[2])
        if changed then
            translated = newText
            hits = hits + 3
        end
    end

    -- 8. Translate remaining individual Russian tokens.
    local out = {}
    for token, spaces in translated:gmatch("(%S+)(%s*)") do
        local prefix, core, suffix = SplitRUToken(token)

        -- English fragments generated by phrase rules are left untouched.
        if ContainsCyrillic(core) then
            local mapped = LookupRUWord(core)
            if mapped then
                token = prefix .. mapped .. suffix
                hits = hits + 1
            end
        end

        out[#out + 1] = token .. spaces
    end

    translated = CleanEnglishSpacing(table.concat(out))
    return FinalizeRUTranslation(translated, hits)
end

local function ShouldTranslateEvent(event)
    if not PartyTranslatorDB then
        return false
    end

    if PartyTranslatorDB.party and
       (event == "CHAT_MSG_PARTY" or event == "CHAT_MSG_PARTY_LEADER") then
        return true
    end

    if PartyTranslatorDB.instance and
       (event == "CHAT_MSG_INSTANCE_CHAT" or event == "CHAT_MSG_INSTANCE_CHAT_LEADER") then
        return true
    end

    if PartyTranslatorDB.raid and
       (event == "CHAT_MSG_RAID" or event == "CHAT_MSG_RAID_LEADER") then
        return true
    end

    if PartyTranslatorDB.whisper and
       (event == "CHAT_MSG_WHISPER" or event == "CHAT_MSG_BN_WHISPER") then
        return true
    end

    return false
end

local function IsSelfAuthor(author)
    if not author or author == "" or not UnitName then return false end
    local me = UnitName("player")
    if not me then return false end
    if author == me then return true end
    if Ambiguate then
        local ok, short = pcall(Ambiguate, author, "short")
        if ok and short == me then return true end
    end
    return false
end

local function IsCurrentlyGrouped()
    if IsInRaid and IsInRaid() then return true end
    if IsInGroup then
        if LE_PARTY_CATEGORY_INSTANCE and IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then return true end
        if IsInGroup() then return true end
    end
    return false
end

local function ChatFilter(self, event, msg, author, ...)
    if not PartyTranslatorDB or not PartyTranslatorDB.enabled or not ShouldTranslateEvent(event) then
        return false, msg, author, ...
    end

    -- Midnight can expose protected chat payloads. Never inspect secret values.
    if issecretvalue and issecretvalue(msg) then
        return false, msg, author, ...
    end

    local whisperKind, whisperTarget
    if event == "CHAT_MSG_WHISPER" then
        whisperKind = "WHISPER"
        whisperTarget = author
    elseif event == "CHAT_MSG_BN_WHISPER" then
        whisperKind = "BN"
        whisperTarget = select(11, ...)
    end

    if PartyTranslatorDB.separateWindow and not IsSelfAuthor(author) and PT.TouchWindowActivity then
        PT.TouchWindowActivity(true)
    end

    -- A whisper tab is created even if the local dictionary cannot translate
    -- the message. This keeps the reply workflow available for every whisper.
    if PartyTranslatorDB.separateWindow and whisperKind and not IsSelfAuthor(author) and IsCurrentlyGrouped() then
        if PT.EnsureWhisperTab then
            PT.EnsureWhisperTab(whisperKind, whisperTarget or author, author)
        end
        if PartyTranslatorDB.autoShowWindow and PT.OpenTranslatorWindow then
            PT.OpenTranslatorWindow(false)
        end
    end

    local ru, translationHits, sourceLanguage, translationStatus = PT.TranslateIncoming(msg)
    if not ru then
        return false, msg, author, ...
    end

    sourceLanguage = sourceLanguage or "EN"
    translationStatus = translationStatus or "FULL"

    -- Used by /pt reportlast. We deliberately do not store player names.
    PT.LastIncomingTranslation = {
        original = tostring(msg or ""),
        translation = tostring(ru or ""),
        status = translationStatus,
        language = sourceLanguage,
        direction = sourceLanguage .. "_RU",
        hits = tonumber(translationHits) or 0,
        context = tostring(event or ""),
    }

    if PartyTranslatorDB.separateWindow then
        if not IsSelfAuthor(author) and PT.AddTranslation then
            PT.AddTranslation(
                event, author, ru, whisperKind, whisperTarget, false, msg,
                sourceLanguage, translationStatus
            )
        end
        return false, msg, author, ...
    end

    local translatedLine = ColorTranslation(
        "[" .. tostring(sourceLanguage or "?") .. "] " .. ru
    )
    local newMsg
    if PartyTranslatorDB.showOriginal then
        newMsg = msg .. "\n" .. translatedLine
    else
        newMsg = translatedLine
    end

    return false, newMsg, author, ...
end

local EVENTS = {
    "CHAT_MSG_PARTY",
    "CHAT_MSG_PARTY_LEADER",
    "CHAT_MSG_INSTANCE_CHAT",
    "CHAT_MSG_INSTANCE_CHAT_LEADER",
    "CHAT_MSG_RAID",
    "CHAT_MSG_RAID_LEADER",
    "CHAT_MSG_WHISPER",
    "CHAT_MSG_BN_WHISPER",
}

local function SendChat(text, chatType, target)
    if C_ChatInfo and C_ChatInfo.SendChatMessage then
        C_ChatInfo.SendChatMessage(text, chatType, nil, target)
    elseif SendChatMessage then
        SendChatMessage(text, chatType, nil, target)
    end
end

local function SmartGroupType()
    if IsInGroup and LE_PARTY_CATEGORY_INSTANCE and IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then return "INSTANCE_CHAT" end
    if IsInRaid and IsInRaid() then return "RAID" end
    if IsInGroup and IsInGroup() then return "PARTY" end
    return nil
end

function PT.ApplyWindowAppearance()
    local f = PT.TranslatorWindow
    if not f then return end

    local alpha = Clamp(PartyTranslatorDB and PartyTranslatorDB.windowAlpha or DEFAULTS.windowAlpha, 0.10, 0.95)
    local scale = 1.0
    local fontSize = math.floor(Clamp(PartyTranslatorDB and PartyTranslatorDB.fontSize or DEFAULTS.fontSize, 11, 22) + 0.5)
    local smallFontSize = math.max(9, fontSize - 2)
    local fontPath = GetWindowFontPath()
    local bgR = Clamp(PartyTranslatorDB and PartyTranslatorDB.backgroundR or DEFAULTS.backgroundR, 0, 1)
    local bgG = Clamp(PartyTranslatorDB and PartyTranslatorDB.backgroundG or DEFAULTS.backgroundG, 0, 1)
    local bgB = Clamp(PartyTranslatorDB and PartyTranslatorDB.backgroundB or DEFAULTS.backgroundB, 0, 1)

    if f.SetScale then
        f:SetScale(scale)
    end

    if f.SetBackdropColor then
        f:SetBackdropColor(bgR, bgG, bgB, alpha)
    end
    if f.SetBackdropBorderColor then
        f:SetBackdropBorderColor(0.32, 0.32, 0.32, math.min(0.85, alpha + 0.30))
    end
    if f.historyBG and f.historyBG.SetBackdropColor then
        f.historyBG:SetBackdropColor(bgR, bgG, bgB, 0)
    end
    if f.bottomBG and f.bottomBG.SetBackdropColor then
        f.bottomBG:SetBackdropColor(bgR, bgG, bgB, 0)
    end
    if f.divider and f.divider.SetColorTexture then
        f.divider:SetColorTexture(0.65, 0.65, 0.65, 0)
    end

    -- Use dedicated font objects. ScrollingMessageFrame reliably reacts to
    -- SetFontObject, while direct SetFont behavior varies between clients.
    if not PT.HistoryFont then
        PT.HistoryFont = CreateFont("PartyTranslatorHistoryFont")
        PT.InputFont = CreateFont("PartyTranslatorInputFont")
        PT.SmallFont = CreateFont("PartyTranslatorSmallFont")
    end

    if PT.HistoryFont then
        if ChatFontNormal and PT.HistoryFont.CopyFontObject then
            PT.HistoryFont:CopyFontObject(ChatFontNormal)
        end
        PT.HistoryFont:SetFont(fontPath, fontSize, "")
    end
    if PT.InputFont then
        if ChatFontNormal and PT.InputFont.CopyFontObject then
            PT.InputFont:CopyFontObject(ChatFontNormal)
        end
        PT.InputFont:SetFont(fontPath, fontSize, "")
    end
    if PT.SmallFont then
        if GameFontDisableSmall and PT.SmallFont.CopyFontObject then
            PT.SmallFont:CopyFontObject(GameFontDisableSmall)
        end
        PT.SmallFont:SetFont(fontPath, smallFontSize, "")
    end

    if f.history and PT.HistoryFont then
        f.history:SetFontObject(PT.HistoryFont)
        if f.history.SetJustifyH then f.history:SetJustifyH("LEFT") end
    end
    if f.input and PT.InputFont then f.input:SetFontObject(PT.InputFont) end
    if f.preview and PT.InputFont then f.preview:SetFontObject(PT.InputFont) end
    if f.ruLabel and PT.InputFont then f.ruLabel:SetFontObject(PT.InputFont) end
    if f.enLabel and PT.InputFont then f.enLabel:SetFontObject(PT.InputFont) end
    if f.status and PT.SmallFont then f.status:SetFontObject(PT.SmallFont) end
    if PT.RefreshChatTabs then PT.RefreshChatTabs() end
end


local function GetConfiguredTabColor(active)
    if active then
        return
            Clamp(PartyTranslatorDB and PartyTranslatorDB.tabActiveR or DEFAULTS.tabActiveR, 0, 1),
            Clamp(PartyTranslatorDB and PartyTranslatorDB.tabActiveG or DEFAULTS.tabActiveG, 0, 1),
            Clamp(PartyTranslatorDB and PartyTranslatorDB.tabActiveB or DEFAULTS.tabActiveB, 0, 1)
    end
    return
        Clamp(PartyTranslatorDB and PartyTranslatorDB.tabInactiveR or DEFAULTS.tabInactiveR, 0, 1),
        Clamp(PartyTranslatorDB and PartyTranslatorDB.tabInactiveG or DEFAULTS.tabInactiveG, 0, 1),
        Clamp(PartyTranslatorDB and PartyTranslatorDB.tabInactiveB or DEFAULTS.tabInactiveB, 0, 1)
end

function PT.ApplyTabAppearance(button, active)
    if not button then return end
    local r, g, b = GetConfiguredTabColor(active)

    if button.SetBackdropColor then
        button:SetBackdropColor(r, g, b, active and 0.96 or 0.78)
    end
    if button.SetBackdropBorderColor then
        if active then
            button:SetBackdropBorderColor(0.70, 0.70, 0.72, 0.90)
        else
            button:SetBackdropBorderColor(0.30, 0.30, 0.32, 0.75)
        end
    end

    local fontString = button.GetFontString and button:GetFontString() or nil
    if fontString then
        if active then
            fontString:SetTextColor(1.00, 0.88, 0.42, 1)
        else
            fontString:SetTextColor(0.82, 0.82, 0.82, 1)
        end
    end

    if button.activeJoin then
        if active then
            local bgR = Clamp(PartyTranslatorDB and PartyTranslatorDB.backgroundR or DEFAULTS.backgroundR, 0, 1)
            local bgG = Clamp(PartyTranslatorDB and PartyTranslatorDB.backgroundG or DEFAULTS.backgroundG, 0, 1)
            local bgB = Clamp(PartyTranslatorDB and PartyTranslatorDB.backgroundB or DEFAULTS.backgroundB, 0, 1)
            local alpha = Clamp(PartyTranslatorDB and PartyTranslatorDB.windowAlpha or DEFAULTS.windowAlpha, 0.10, 0.95)
            button.activeJoin:SetColorTexture(bgR, bgG, bgB, alpha)
            button.activeJoin:Show()
        else
            button.activeJoin:Hide()
        end
    end
end

local CHANNEL_LABELS = {
    CHAT_MSG_PARTY = "Группа",
    CHAT_MSG_PARTY_LEADER = "Группа",
    CHAT_MSG_INSTANCE_CHAT = "Ключ",
    CHAT_MSG_INSTANCE_CHAT_LEADER = "Ключ",
    CHAT_MSG_RAID = "Рейд",
    CHAT_MSG_RAID_LEADER = "Рейд",
    CHAT_MSG_WHISPER = "ЛС",
    CHAT_MSG_BN_WHISPER = "BNet",
    PARTY = "Группа",
    INSTANCE_CHAT = "Ключ",
    RAID = "Рейд",
    WHISPER = "ЛС",
    BN_WHISPER = "BNet",
}

local WHISPER_TAB_TIMEOUT = 600
local MAX_TAB_HISTORY = 250

local function ChatNow()
    if GetTime then return GetTime() end
    if time then return time() end
    return 0
end

local function DisplayAuthor(author)
    if not author or author == "" then return "?" end
    if Ambiguate then
        local ok, short = pcall(Ambiguate, author, "short")
        if ok and short and short ~= "" then return short end
    end
    return tostring(author)
end

local function EnsureChatState()
    if not PT.ChatState then
        PT.ChatState = { tabs = {}, order = {}, activeKey = "group" }
    end
    local state = PT.ChatState
    if not state.tabs.group then
        state.tabs.group = {
            key = "group",
            kind = "GROUP",
            name = "Группа",
            history = {},
            lastActivity = ChatNow(),
        }
        table.insert(state.order, 1, "group")
    end
    if not state.activeKey or not state.tabs[state.activeKey] then
        state.activeKey = "group"
    end
    return state
end

local function WhisperTabKey(kind, target, author)
    local id = target or author or "unknown"
    return (kind == "BN" and "bn:" or "wow:") .. tostring(id)
end

local function GetActiveTab()
    local state = EnsureChatState()
    return state.tabs[state.activeKey], state
end

local function TranslationReportTimestamp()
    if date then
        return date("%Y-%m-%d %H:%M:%S")
    end
    return tostring(time and time() or 0)
end

local function TranslationReportKey(report)
    return table.concat({
        tostring(report.direction or ""),
        tostring(report.original or ""),
        tostring(report.translation or ""),
        tostring(report.status or ""),
    }, "\031")
end

function PT.GetTranslationReportCount()
    local reports = PartyTranslatorDB and PartyTranslatorDB.translationReports
    if type(reports) ~= "table" then return 0 end
    return #reports
end

function PT.SaveTranslationReport(report)
    if type(report) ~= "table" then
        return false, "Нет данных для жалобы."
    end

    PartyTranslatorDB = PartyTranslatorDB or {}
    PartyTranslatorDB.translationReports = PartyTranslatorDB.translationReports or {}

    local original = Trim(tostring(report.original or ""))
    local translation = Trim(tostring(report.translation or ""))

    if original == "" then
        return false, "Нет исходного текста для жалобы."
    end

    local now = TranslationReportTimestamp()
    local item = {
        direction = tostring(report.direction or "UNKNOWN"),
        original = original,
        translation = translation,
        status = tostring(report.status or "UNKNOWN"),
        context = tostring(report.context or ""),
        engine = tostring(report.engine or RU_ENGINE_VERSION),
        firstSeen = now,
        lastSeen = now,
        count = 1,
    }

    local wantedKey = TranslationReportKey(item)
    local reports = PartyTranslatorDB.translationReports

    -- Identical complaints are deduplicated and counted.
    for i = #reports, 1, -1 do
        local existing = reports[i]
        if type(existing) == "table" and TranslationReportKey(existing) == wantedKey then
            existing.count = (tonumber(existing.count) or 1) + 1
            existing.lastSeen = now
            existing.engine = item.engine
            if item.context ~= "" then existing.context = item.context end
            return true, i, existing.count
        end
    end

    reports[#reports + 1] = item
    return true, #reports, 1
end

function PT.ReportOutgoingTranslation(original, translation, translationStatus)
    local tab = GetActiveTab()
    local context = tab and tostring(tab.kind or "") or ""

    return PT.SaveTranslationReport({
        direction = "RU_EN",
        original = original,
        translation = translation,
        status = translationStatus or "UNKNOWN",
        context = context,
        engine = RU_ENGINE_VERSION,
    })
end

function PT.ReportLastIncomingTranslation()
    local last = PT.LastIncomingTranslation
    if type(last) ~= "table" or not last.original then
        return false, "Пока нет входящего перевода для жалобы."
    end

    return PT.SaveTranslationReport({
        direction = last.direction or ((last.language or "EN") .. "_RU"),
        original = last.original,
        translation = last.translation,
        status = last.status or "FULL",
        context = last.context or "",
        engine = RU_ENGINE_VERSION,
    })
end

local function SaveWindowGeometry(f)
    if not PartyTranslatorDB or not f or f.isMinimized then return end
    PartyTranslatorDB.windowWidth = math.floor(f:GetWidth() + 0.5)
    PartyTranslatorDB.windowHeight = math.floor(f:GetHeight() + 0.5)
    local cx, cy = f:GetCenter()
    local ux, uy = UIParent:GetCenter()
    if cx and cy and ux and uy then
        PartyTranslatorDB.windowX = math.floor((cx - ux) + 0.5)
        PartyTranslatorDB.windowY = math.floor((cy - uy) + 0.5)
    end
end

local function ApplyConfiguredWindowGeometry(f)
    if not f or f.isMinimized then return end

    local width = tonumber(PartyTranslatorDB and PartyTranslatorDB.windowWidth)
        or DEFAULTS.windowWidth
    local height = tonumber(PartyTranslatorDB and PartyTranslatorDB.windowHeight)
        or DEFAULTS.windowHeight

    -- Never allow a broken first-frame geometry to survive startup.
    if width < 300 or height < 170 then
        width = DEFAULTS.windowWidth
        height = DEFAULTS.windowHeight
        if PartyTranslatorDB then
            PartyTranslatorDB.windowWidth = width
            PartyTranslatorDB.windowHeight = height
        end
    end

    width = Clamp(width, 150, 900)
    height = Clamp(height, 150, 700)

    local x = tonumber(PartyTranslatorDB and PartyTranslatorDB.windowX) or 0
    local y = tonumber(PartyTranslatorDB and PartyTranslatorDB.windowY) or 0

    -- Suppress OnSizeChanged persistence while startup geometry is applied.
    f.ptApplyingGeometry = true
    f:ClearAllPoints()
    f:SetSize(width, height)
    f:SetPoint("CENTER", UIParent, "CENTER", x, y)
    f.ptApplyingGeometry = false
end

function PT.ApplyConfiguredWindowGeometry()
    ApplyConfiguredWindowGeometry(PT.TranslatorWindow)
end


-- ============================================================================
-- Per-message translation reporting.
--
-- Each translated line in the custom history gets a volatile report ID.
-- Right-clicking the translated text opens a small context menu. The actual
-- complaint is then copied into SavedVariables through PT.SaveTranslationReport.
-- ============================================================================

PT.HistoryReportTargets = PT.HistoryReportTargets or {}
PT.HistoryReportNextID = PT.HistoryReportNextID or 0

local function RegisterHistoryReport(direction, original, translation, status, context)
    original = Trim(tostring(original or ""))
    translation = Trim(tostring(translation or ""))
    if original == "" then return nil end

    PT.HistoryReportNextID = (tonumber(PT.HistoryReportNextID) or 0) + 1
    local id = PT.HistoryReportNextID

    PT.HistoryReportTargets[id] = {
        direction = tostring(direction or "UNKNOWN"),
        original = original,
        translation = translation,
        status = tostring(status or "FULL"),
        context = tostring(context or ""),
        engine = RU_ENGINE_VERSION,
    }

    -- Keep the volatile table bounded. Saved complaints live separately.
    local oldest = id - 500
    if oldest > 0 then
        PT.HistoryReportTargets[oldest] = nil
    end

    return id
end

local function ReportableTranslationText(reportID, translatedText, colorHex)
    translatedText = tostring(translatedText or "")
    if not reportID then
        if colorHex then
            return "|cff" .. colorHex .. translatedText .. "|r"
        end
        return translatedText
    end

    -- WoW hyperlinks cannot safely be nested. If the translation already
    -- contains an item/spell/etc. hyperlink, keep it intact and append a
    -- compact right-click target instead.
    if translatedText:find("|H", 1, true) then
        local body = colorHex and ("|cff" .. colorHex .. translatedText .. "|r") or translatedText
        return body .. " |cff888888|Hpartytranslatorreport:" .. tostring(reportID) .. "|h[ПКМ]|h|r"
    end

    local prefix = colorHex and ("|cff" .. colorHex) or ""
    local suffix = colorHex and "|r" or ""
    return prefix
        .. "|Hpartytranslatorreport:" .. tostring(reportID)
        .. "|h" .. translatedText .. "|h"
        .. suffix
end

local function EnsureTranslatorWindow()
    if PT.TranslatorWindow then return PT.TranslatorWindow end

    EnsureChatState()

    local f = CreateFrame("Frame", "PartyTranslatorWindow", UIParent, "BackdropTemplate")
    local width = PartyTranslatorDB and tonumber(PartyTranslatorDB.windowWidth) or DEFAULTS.windowWidth
    local height = PartyTranslatorDB and tonumber(PartyTranslatorDB.windowHeight) or DEFAULTS.windowHeight
    if width < 300 or height < 170 then
        width = DEFAULTS.windowWidth
        height = DEFAULTS.windowHeight
        if PartyTranslatorDB then
            PartyTranslatorDB.windowWidth = width
            PartyTranslatorDB.windowHeight = height
        end
    end
    width = math.max(150, math.min(width, 900))
    height = math.max(150, math.min(height, 700))
    f:SetSize(width, height)
    f:SetPoint("CENTER", UIParent, "CENTER", PartyTranslatorDB and tonumber(PartyTranslatorDB.windowX) or 0, PartyTranslatorDB and tonumber(PartyTranslatorDB.windowY) or 0)
    f:SetFrameStrata("DIALOG")
    f:SetMovable(true)
    f:SetResizable(true)
    if f.SetResizeBounds then
        f:SetResizeBounds(150, 150, 900, 700)
    elseif f.SetMinResize then
        f:SetMinResize(150, 150)
        if f.SetMaxResize then f:SetMaxResize(900, 700) end
    end
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", function(self)
        if not self.isMinimized then self:StartMoving() end
    end)
    f:SetScript("OnDragStop", function(self) self:StopMovingOrSizing(); SaveWindowGeometry(self) end)
    f:SetClampedToScreen(true)
    f:SetBackdrop({
        bgFile = "Interface/ChatFrame/ChatFrameBackground",
        edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
        edgeSize = 12,
        insets = { left=2, right=2, top=2, bottom=2 },
    })

    local tabBar = CreateFrame("Frame", nil, f)
    tabBar:SetPoint("TOPLEFT", 8, -5)
    tabBar:SetPoint("TOPRIGHT", -32, -5)
    tabBar:SetHeight(22)

    local minimizeButton = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    minimizeButton:SetSize(20, 20)
    minimizeButton:SetPoint("TOPRIGHT", -7, -6)
    minimizeButton:SetText("–")
    minimizeButton:SetScript("OnClick", function()
        if PT.MinimizeTranslatorWindow then PT.MinimizeTranslatorWindow() end
    end)

    local reloadButton = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    reloadButton:SetSize(20, 20)
    reloadButton:SetPoint("RIGHT", minimizeButton, "LEFT", -4, 0)
    reloadButton:SetText("R")
    reloadButton:SetScript("OnEnter", function(self)
        if not GameTooltip then return end
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
        GameTooltip:SetText("Перезагрузить интерфейс")
        GameTooltip:AddLine("Кнопка R выполняет команду /reload.", 1, 1, 1, true)
        GameTooltip:Show()
    end)
    reloadButton:SetScript("OnLeave", function()
        if GameTooltip then GameTooltip:Hide() end
    end)
    reloadButton:SetScript("OnClick", function()
        if ReloadUI then ReloadUI() end
    end)

    local restoreButton = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    restoreButton:SetAllPoints(f)
    restoreButton:SetText("PT")
    restoreButton:SetScript("OnClick", function()
        if PT.RestoreTranslatorWindow then PT.RestoreTranslatorWindow(true) end
    end)
    restoreButton:Hide()

    -- Compact-mode unread counter.
    local minimizedUnreadBadge = restoreButton:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    minimizedUnreadBadge:SetPoint("TOPRIGHT", restoreButton, "TOPRIGHT", -4, -3)
    minimizedUnreadBadge:SetJustifyH("RIGHT")
    minimizedUnreadBadge:SetText("")
    minimizedUnreadBadge:Hide()

    -- Temporary translation toast shown while the main window is minimized.
    local minimizedToast = CreateFrame("Frame", nil, f, "BackdropTemplate")
    minimizedToast:SetSize(360, 48)
    minimizedToast:SetPoint("BOTTOMRIGHT", f, "TOPRIGHT", 0, 4)
    minimizedToast:SetFrameStrata("DIALOG")
    minimizedToast:SetBackdrop({
        bgFile = "Interface/ChatFrame/ChatFrameBackground",
        edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
        edgeSize = 10,
        insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    minimizedToast:SetBackdropColor(0, 0, 0, 0.82)
    minimizedToast:EnableMouse(false)

    local minimizedToastText = minimizedToast:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    minimizedToastText:SetPoint("TOPLEFT", 8, -6)
    minimizedToastText:SetPoint("BOTTOMRIGHT", -8, 6)
    minimizedToastText:SetJustifyH("LEFT")
    minimizedToastText:SetJustifyV("MIDDLE")
    minimizedToastText:SetWordWrap(true)
    minimizedToastText:SetText("")
    minimizedToast:Hide()

    local historyBG = CreateFrame("Frame", nil, f, "BackdropTemplate")
    historyBG:SetPoint("TOPLEFT", 8, -31)
    historyBG:SetPoint("BOTTOMRIGHT", -8, 82)
    historyBG:SetBackdrop({ bgFile = "Interface/ChatFrame/ChatFrameBackground" })
    historyBG:SetBackdropColor(0, 0, 0, 0)

    local history = CreateFrame("ScrollingMessageFrame", nil, historyBG)
    history:SetPoint("TOPLEFT", 5, -4)
    history:SetPoint("BOTTOMRIGHT", -5, 4)
    history:SetFading(false)
    history:SetMaxLines(MAX_TAB_HISTORY)
    history:SetJustifyH("LEFT")
    if history.SetIndentedWordWrap then history:SetIndentedWordWrap(false) end
    if history.SetSpacing then history:SetSpacing(2) end
    history:EnableMouseWheel(true)
    history:EnableMouse(true)
    if history.SetHyperlinksEnabled then history:SetHyperlinksEnabled(true) end
    history:SetScript("OnMouseWheel", function(self, delta)
        if delta > 0 then self:ScrollUp() else self:ScrollDown() end
    end)

    local translationReportMenu = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    translationReportMenu:SetSize(190, 88)
    translationReportMenu:SetFrameStrata("TOOLTIP")
    translationReportMenu:SetClampedToScreen(true)
    translationReportMenu:SetBackdrop({
        bgFile = "Interface/ChatFrame/ChatFrameBackground",
        edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
        edgeSize = 10,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    translationReportMenu:SetBackdropColor(0.03, 0.03, 0.03, 0.96)
    translationReportMenu:Hide()

    local translationReportTitle = translationReportMenu:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    translationReportTitle:SetPoint("TOPLEFT", 9, -7)
    translationReportTitle:SetText("Перевод")

    local translationReportButton = CreateFrame("Button", nil, translationReportMenu, "UIPanelButtonTemplate")
    translationReportButton:SetSize(174, 24)
    translationReportButton:SetPoint("TOPLEFT", 8, -23)
    translationReportButton:SetText("Ошибка перевода")

    local translationReportCloseButton = CreateFrame("Button", nil, translationReportMenu, "UIPanelButtonTemplate")
    translationReportCloseButton:SetSize(174, 22)
    translationReportCloseButton:SetPoint("TOPLEFT", translationReportButton, "BOTTOMLEFT", 0, -4)
    translationReportCloseButton:SetText("Закрыть")

    local function HideTranslationReportMenu()
        translationReportMenu.reportID = nil
        translationReportMenu:Hide()
    end

    translationReportCloseButton:SetScript("OnClick", HideTranslationReportMenu)

    local function ShowTranslationReportMenu(reportID)
        reportID = tonumber(reportID)
        local target = reportID and PT.HistoryReportTargets[reportID] or nil
        if not target then return end

        translationReportMenu.reportID = reportID

        local x, y = GetCursorPosition()
        local scale = UIParent:GetEffectiveScale()
        x, y = x / scale, y / scale

        translationReportMenu:ClearAllPoints()
        translationReportMenu:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x + 4, y - 4)
        translationReportMenu:Show()

        if C_Timer and C_Timer.After then
            local shownID = reportID
            C_Timer.After(8, function()
                if translationReportMenu:IsShown()
                    and translationReportMenu.reportID == shownID then
                    HideTranslationReportMenu()
                end
            end)
        end
    end

    translationReportButton:SetScript("OnClick", function()
        local reportID = tonumber(translationReportMenu.reportID)
        local target = reportID and PT.HistoryReportTargets[reportID] or nil
        if not target then
            HideTranslationReportMenu()
            return
        end

        local ok, index, duplicateCount = PT.SaveTranslationReport(target)
        HideTranslationReportMenu()

        if ok then
            local suffix = ""
            if tonumber(duplicateCount) and tonumber(duplicateCount) > 1 then
                suffix = " (повтор x" .. tostring(duplicateCount) .. ")"
            end
            Print("ошибка перевода добавлена #" .. tostring(index) .. suffix
                .. ". Для Companion выполните /reload или выйдите из игры.")
        else
            Print(tostring(index or "Не удалось сохранить ошибку перевода."))
        end
    end)

    history:SetScript("OnHyperlinkClick", function(self, link, displayText, button)
        local reportID = tostring(link or ""):match("^partytranslatorreport:(%d+)$")
        if reportID then
            if button == "RightButton" then
                ShowTranslationReportMenu(reportID)
            end
            return
        end
    end)

    history:SetScript("OnMouseDown", function(self, button)
        if button == "LeftButton" and translationReportMenu:IsShown() then
            HideTranslationReportMenu()
        end
    end)

    local divider = f:CreateTexture(nil, "BORDER")
    divider:SetPoint("LEFT", 8, 77)
    divider:SetPoint("RIGHT", -8, 77)
    divider:SetHeight(1)

    local bottomBG = CreateFrame("Frame", nil, f, "BackdropTemplate")
    bottomBG:SetPoint("TOPLEFT", divider, "BOTTOMLEFT", 0, -1)
    bottomBG:SetPoint("BOTTOMRIGHT", -8, 8)
    bottomBG:SetBackdrop({ bgFile = "Interface/ChatFrame/ChatFrameBackground" })
    bottomBG:SetBackdropColor(0, 0, 0, 0)

    local ruLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    ruLabel:SetPoint("BOTTOMLEFT", 12, 52)
    ruLabel:SetText("RU:")

    local input = CreateFrame("EditBox", nil, f, "InputBoxTemplate")
    input:SetPoint("LEFT", ruLabel, "RIGHT", 7, 0)
    input:SetPoint("RIGHT", f, "RIGHT", -12, 0)
    input:SetHeight(24)
    input:SetAutoFocus(false)
    input:SetMaxLetters(240)

    local enLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    enLabel:SetPoint("BOTTOMLEFT", 12, 29)
    enLabel:SetText("EN:")

    local reportButton = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    reportButton:SetSize(22, 20)
    reportButton:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -12, 20)
    reportButton:SetText("!")
    reportButton:SetScript("OnEnter", function(self)
        if not GameTooltip then return end
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText("Ошибка перевода")
        GameTooltip:AddLine("Сохранить текущий RU→EN перевод в журнал ошибок.", 1, 1, 1, true)
        GameTooltip:AddLine("WoW запишет SavedVariables после /reload или выхода.", 0.75, 0.75, 0.75, true)
        GameTooltip:Show()
    end)
    reportButton:SetScript("OnLeave", function()
        if GameTooltip then GameTooltip:Hide() end
    end)

    local preview = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    preview:SetPoint("LEFT", enLabel, "RIGHT", 7, 0)
    preview:SetPoint("RIGHT", reportButton, "LEFT", -6, 0)
    preview:SetHeight(20)
    preview:SetJustifyH("LEFT")
    preview:SetJustifyV("MIDDLE")
    preview:SetText("—")

    local status = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    status:SetPoint("BOTTOMLEFT", 12, 10)
    status:SetPoint("RIGHT", f, "RIGHT", -12, 0)
    status:SetJustifyH("LEFT")
    status:SetText("Enter — отправить в активный чат")

    local resize = CreateFrame("Button", nil, f)
    resize:SetSize(16, 16)
    resize:SetPoint("BOTTOMRIGHT", -2, 2)
    local tex = resize:CreateTexture(nil, "ARTWORK")
    tex:SetAllPoints()
    tex:SetTexture("Interface/ChatFrame/UI-ChatIM-SizeGrabber-Up")
    resize:SetNormalTexture(tex)
    resize:SetScript("OnMouseDown", function(_, button)
        if button == "LeftButton" then f:StartSizing("BOTTOMRIGHT") end
    end)
    resize:SetScript("OnMouseUp", function()
        f:StopMovingOrSizing()
        SaveWindowGeometry(f)
    end)

    local function UpdatePreview()
        local text = input:GetText() or ""
        local en, _, translationStatus, partial = PT.TranslateRU(text)

        f.currentReportOriginal = text
        f.currentReportStatus = translationStatus or (Trim(text) ~= "" and "FAILED" or "EMPTY")

        if en then
            preview:SetText(en)
            f.currentReportTranslation = en
            status:SetText("Enter — отправить в активный чат")
        elseif translationStatus == "PARTIAL" and partial then
            preview:SetText("[PARTIAL] " .. partial)
            f.currentReportTranslation = partial
            status:SetText("Неполный перевод — отправка заблокирована")
        else
            preview:SetText("—")
            f.currentReportTranslation = ""
            status:SetText("Enter — отправить в активный чат")
        end

        if text ~= "" and PT.TouchWindowActivity then PT.TouchWindowActivity(false) end
        local tab = GetActiveTab()
        if tab and tab.kind ~= "GROUP" and text ~= "" then
            tab.lastActivity = ChatNow()
        end
    end

    reportButton:SetScript("OnClick", function()
        local original = Trim(f.currentReportOriginal or input:GetText() or "")
        if original == "" then
            status:SetText("Введите фразу, перевод которой нужно отметить.")
            return
        end

        local ok, index, duplicateCount = PT.ReportOutgoingTranslation(
            original,
            f.currentReportTranslation or "",
            f.currentReportStatus or "UNKNOWN"
        )

        if ok then
            local suffix = ""
            if tonumber(duplicateCount) and tonumber(duplicateCount) > 1 then
                suffix = " (повтор x" .. tostring(duplicateCount) .. ")"
            end
            status:SetText("Ошибка #" .. tostring(index) .. suffix .. " добавлена. Для ПО: /reload")
        else
            status:SetText(tostring(index or "Не удалось сохранить ошибку перевода."))
        end
    end)

    local function SendActive()
        local text = input:GetText() or ""
        if Trim(text) == "" then return end
        local ok, result = PT.SendTranslatedActive(text)
        if ok then
            input:SetText("")
            if PT.UpdateActiveChatStatus then PT.UpdateActiveChatStatus() end
        else
            status:SetText(result or "Ошибка отправки")
        end
    end

    input:SetScript("OnTextChanged", UpdatePreview)
    input:SetScript("OnEditFocusGained", function()
        if PT.TouchWindowActivity then PT.TouchWindowActivity(false) end
    end)
    input:SetScript("OnEnterPressed", SendActive)
    input:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    f:SetScript("OnSizeChanged", function(self)
        if not self.ptApplyingGeometry then
            SaveWindowGeometry(self)
        end
    end)
    f:SetScript("OnShow", function(self)
        if not self.isMinimized then
            ApplyConfiguredWindowGeometry(self)
        end
        PT.ApplyWindowAppearance()
        if PT.ApplyWindowControlSettings then PT.ApplyWindowControlSettings() end
        if PT.PruneWhisperTabs then PT.PruneWhisperTabs() end
        if not self.isMinimized then
            if PT.RefreshChatTabs then PT.RefreshChatTabs() end
            if PT.RenderActiveHistory then PT.RenderActiveHistory() end
        end
    end)
    f:SetScript("OnUpdate", function(self, elapsed)
        if self.minimizedToast and self.minimizedToast:IsShown()
            and self.minimizedToastUntil
            and ChatNow() >= self.minimizedToastUntil then
            self.minimizedToast:Hide()
            self.minimizedToastUntil = nil
        end

        self.ptWhisperCleanupElapsed = (self.ptWhisperCleanupElapsed or 0) + elapsed
        if self.ptWhisperCleanupElapsed >= 5 then
            self.ptWhisperCleanupElapsed = 0
            if PT.PruneWhisperTabs then PT.PruneWhisperTabs() end
        end

        self.ptAutoMinimizeElapsed = (self.ptAutoMinimizeElapsed or 0) + elapsed
        if self.ptAutoMinimizeElapsed >= 1 then
            self.ptAutoMinimizeElapsed = 0
            if PartyTranslatorDB and PartyTranslatorDB.autoMinimize and not self.isMinimized then
                local delayMinutes = Clamp(PartyTranslatorDB.autoMinimizeDelay or DEFAULTS.autoMinimizeDelay, 0.5, 10)
                local idle = ChatNow() - (self.lastChatActivity or ChatNow())
                local composing = self.input and ((self.input:HasFocus()) or Trim(self.input:GetText() or "") ~= "")
                if not composing and idle >= delayMinutes * 60 then
                    if PT.MinimizeTranslatorWindow then PT.MinimizeTranslatorWindow() end
                end
            end
        end
    end)

    f.tabBar = tabBar
    f.minimizeButton = minimizeButton
    f.reloadButton = reloadButton
    f.restoreButton = restoreButton
    f.minimizedUnreadBadge = minimizedUnreadBadge
    f.minimizedToast = minimizedToast
    f.minimizedToastText = minimizedToastText
    f.minimizedUnreadCount = 0
    f.translationReportMenu = translationReportMenu
    f.tabButtons = {}
    f.historyBG = historyBG
    f.bottomBG = bottomBG
    f.divider = divider
    f.history = history
    f.input = input
    f.preview = preview
    f.reportButton = reportButton
    f.status = status
    f.ruLabel = ruLabel
    f.enLabel = enLabel
    f.resize = resize
    f.normalWidgets = { tabBar, historyBG, divider, bottomBG, ruLabel, input, enLabel, preview, reportButton, status, resize, reloadButton, minimizeButton }
    f.lastChatActivity = ChatNow()

    PT.TranslatorWindow = f
    ApplyConfiguredWindowGeometry(f)
    PT.ApplyWindowControlSettings()
    PT.ApplyWindowAppearance()
    if PT.RefreshChatTabs then PT.RefreshChatTabs() end
    if PT.RenderActiveHistory then PT.RenderActiveHistory() end
    f:Hide()
    return f
end

local COLLAPSED_WIDTH = 80
local COLLAPSED_HEIGHT = 40

function PT.UpdateMinimizedIndicator()
    local f = PT.TranslatorWindow
    if not f then return end

    local count = tonumber(f.minimizedUnreadCount) or 0
    if f.minimizedUnreadBadge then
        if f.isMinimized and count > 0 then
            f.minimizedUnreadBadge:SetText(count > 99 and "99+" or tostring(count))
            f.minimizedUnreadBadge:Show()
        else
            f.minimizedUnreadBadge:SetText("")
            f.minimizedUnreadBadge:Hide()
        end
    end

    if f.restoreButton then
        f.restoreButton:SetText("PT")
    end
end

function PT.ShowMinimizedTranslation(line)
    local f = PT.TranslatorWindow
    if not f or not f.isMinimized or not line then return end

    f.minimizedUnreadCount = (tonumber(f.minimizedUnreadCount) or 0) + 1
    PT.UpdateMinimizedIndicator()

    if f.minimizedToastText and f.minimizedToast then
        f.minimizedToastText:SetText(tostring(line))
        f.minimizedToastUntil = ChatNow() + 6
        f.minimizedToast:Show()
    end
end

function PT.ApplyWindowControlSettings()
    local f = PT.TranslatorWindow
    if not f then return end

    local showButton = PartyTranslatorDB and PartyTranslatorDB.showMinimizeButton ~= false
    if f.minimizeButton then
        if showButton and not f.isMinimized then f.minimizeButton:Show() else f.minimizeButton:Hide() end
    end

    if f.reloadButton then
        if not f.isMinimized then
            f.reloadButton:ClearAllPoints()
            if showButton and f.minimizeButton then
                f.reloadButton:SetPoint("RIGHT", f.minimizeButton, "LEFT", -4, 0)
            else
                f.reloadButton:SetPoint("TOPRIGHT", -7, -6)
            end
            f.reloadButton:Show()
        else
            f.reloadButton:Hide()
        end
    end

    if f.tabBar and not f.isMinimized then
        f.tabBar:ClearAllPoints()
        f.tabBar:SetPoint("TOPLEFT", 8, -5)
        -- Reserve room for reload + minimize buttons.
        f.tabBar:SetPoint("TOPRIGHT", showButton and -56 or -32, -5)
    end

    if not (PartyTranslatorDB and PartyTranslatorDB.autoMinimize) then
        f.lastChatActivity = ChatNow()
    end
end

function PT.TouchWindowActivity(wakeWindow)
    local f = PT.TranslatorWindow
    if not f then return end
    f.lastChatActivity = ChatNow()

    if wakeWindow and f.isMinimized and PartyTranslatorDB and PartyTranslatorDB.autoShowWindow then
        PT.RestoreTranslatorWindow(false)
    end
end

function PT.MinimizeTranslatorWindow()
    local f = EnsureTranslatorWindow()
    if f.isMinimized then return true end

    SaveWindowGeometry(f)
    local right = f:GetRight()
    local bottom = f:GetBottom()
    local uiLeft = UIParent:GetLeft() or 0
    local uiBottom = UIParent:GetBottom() or 0

    f.isMinimized = true
    f.minimizedUnreadCount = 0
    f:SetResizable(false)
    if f.input then f.input:ClearFocus() end

    for _, widget in ipairs(f.normalWidgets or {}) do
        if widget then widget:Hide() end
    end

    f:ClearAllPoints()
    f:SetSize(COLLAPSED_WIDTH, COLLAPSED_HEIGHT)
    if right and bottom then
        f:SetPoint("BOTTOMRIGHT", UIParent, "BOTTOMLEFT", right - uiLeft, bottom - uiBottom)
    else
        f:SetPoint("CENTER", UIParent, "CENTER", PartyTranslatorDB.windowX or 0, PartyTranslatorDB.windowY or 0)
    end

    if f.restoreButton then
        f.restoreButton:Show()
        f.restoreButton:SetText("PT")
    end
    if f.minimizedToast then
        f.minimizedToast:Hide()
        f.minimizedToastUntil = nil
    end
    PT.UpdateMinimizedIndicator()
    PT.ApplyWindowAppearance()
    return true
end

function PT.RestoreTranslatorWindow(focusInput)
    local f = EnsureTranslatorWindow()
    if not f.isMinimized then
        if focusInput and f.input then f.input:SetFocus() end
        PT.TouchWindowActivity(false)
        return true
    end

    f.isMinimized = false
    f.minimizedUnreadCount = 0
    if f.restoreButton then f.restoreButton:Hide() end
    if f.minimizedToast then
        f.minimizedToast:Hide()
        f.minimizedToastUntil = nil
    end
    PT.UpdateMinimizedIndicator()

    local activeTab = GetActiveTab()
    if activeTab then activeTab.unread = false end

    ApplyConfiguredWindowGeometry(f)
    f:SetResizable(true)

    for _, widget in ipairs(f.normalWidgets or {}) do
        if widget and widget ~= f.minimizeButton then widget:Show() end
    end
    PT.ApplyWindowControlSettings()
    PT.ApplyWindowAppearance()
    if PT.RefreshChatTabs then PT.RefreshChatTabs() end
    if PT.RenderActiveHistory then PT.RenderActiveHistory() end
    f.lastChatActivity = ChatNow()

    if focusInput and f.input then f.input:SetFocus() end
    return true
end

function PT.SetWindowBaseSize(width, height)
    local f = EnsureTranslatorWindow()
    local newWidth = Clamp(width or PartyTranslatorDB.windowWidth or DEFAULTS.windowWidth, 150, 900)
    local newHeight = Clamp(height or PartyTranslatorDB.windowHeight or DEFAULTS.windowHeight, 150, 700)

    PartyTranslatorDB.windowWidth = math.floor(newWidth + 0.5)
    PartyTranslatorDB.windowHeight = math.floor(newHeight + 0.5)

    if not f.isMinimized then
        f:SetSize(PartyTranslatorDB.windowWidth, PartyTranslatorDB.windowHeight)
        SaveWindowGeometry(f)
    end
    PT.ApplyWindowAppearance()
end

function PT.OpenBackgroundColorPicker()
    if not PartyTranslatorDB or not ColorPickerFrame then return false end

    local startR = Clamp(PartyTranslatorDB.backgroundR or DEFAULTS.backgroundR, 0, 1)
    local startG = Clamp(PartyTranslatorDB.backgroundG or DEFAULTS.backgroundG, 0, 1)
    local startB = Clamp(PartyTranslatorDB.backgroundB or DEFAULTS.backgroundB, 0, 1)
    local startA = Clamp(PartyTranslatorDB.windowAlpha or DEFAULTS.windowAlpha, 0.10, 0.95)

    local function applyColor(r, g, b, a)
        PartyTranslatorDB.backgroundR = Clamp(r, 0, 1)
        PartyTranslatorDB.backgroundG = Clamp(g, 0, 1)
        PartyTranslatorDB.backgroundB = Clamp(b, 0, 1)
        if a ~= nil then
            PartyTranslatorDB.windowAlpha = Clamp(a, 0.10, 0.95)
        end
        PT.ApplyWindowAppearance()
    end

    local function getFrameColor()
        if ColorPickerFrame.GetColorRGB then
            return ColorPickerFrame:GetColorRGB()
        end
        if ColorPickerFrame.Content and ColorPickerFrame.Content.ColorPicker and ColorPickerFrame.Content.ColorPicker.GetColorRGB then
            return ColorPickerFrame.Content.ColorPicker:GetColorRGB()
        end
        return startR, startG, startB
    end

    local function getFrameAlpha()
        if OpacitySliderFrame and OpacitySliderFrame.GetValue then
            return 1 - OpacitySliderFrame:GetValue()
        end
        if ColorPickerFrame.opacity ~= nil then
            return 1 - ColorPickerFrame.opacity
        end
        return PartyTranslatorDB.windowAlpha or startA
    end

    local function onChanged()
        local r, g, b = getFrameColor()
        local a = getFrameAlpha()
        applyColor(r, g, b, a)
    end

    local function onCancel(previousValues)
        if type(previousValues) == "table" then
            local r = previousValues.r or previousValues[1] or startR
            local g = previousValues.g or previousValues[2] or startG
            local b = previousValues.b or previousValues[3] or startB
            local opacity = previousValues.opacity
            if opacity == nil then opacity = previousValues[4] end
            local a = opacity and (1 - opacity) or startA
            applyColor(r, g, b, a)
        else
            applyColor(startR, startG, startB, startA)
        end
    end

    if ColorPickerFrame.SetupColorPickerAndShow then
        local info = {
            r = startR,
            g = startG,
            b = startB,
            opacity = 1 - startA,
            hasOpacity = true,
            swatchFunc = onChanged,
            opacityFunc = onChanged,
            cancelFunc = onCancel,
        }
        ColorPickerFrame:SetupColorPickerAndShow(info)
        return true
    end

    ColorPickerFrame.func = onChanged
    ColorPickerFrame.opacityFunc = onChanged
    ColorPickerFrame.cancelFunc = onCancel
    ColorPickerFrame.hasOpacity = true
    ColorPickerFrame.opacity = 1 - startA
    if ColorPickerFrame.SetColorRGB then
        ColorPickerFrame:SetColorRGB(startR, startG, startB)
    end
    ColorPickerFrame.previousValues = { r = startR, g = startG, b = startB, opacity = 1 - startA }
    if ColorPickerFrame.Show then
        ColorPickerFrame:Show()
        return true
    end
    return false
end


function PT.OpenTabColorPicker(which)
    if not PartyTranslatorDB or not ColorPickerFrame then return false end

    local active = which == "active"
    local rKey = active and "tabActiveR" or "tabInactiveR"
    local gKey = active and "tabActiveG" or "tabInactiveG"
    local bKey = active and "tabActiveB" or "tabInactiveB"
    local defaultR = active and DEFAULTS.tabActiveR or DEFAULTS.tabInactiveR
    local defaultG = active and DEFAULTS.tabActiveG or DEFAULTS.tabInactiveG
    local defaultB = active and DEFAULTS.tabActiveB or DEFAULTS.tabInactiveB

    local startR = Clamp(PartyTranslatorDB[rKey] or defaultR, 0, 1)
    local startG = Clamp(PartyTranslatorDB[gKey] or defaultG, 0, 1)
    local startB = Clamp(PartyTranslatorDB[bKey] or defaultB, 0, 1)

    local function applyColor(r, g, b)
        PartyTranslatorDB[rKey] = Clamp(r, 0, 1)
        PartyTranslatorDB[gKey] = Clamp(g, 0, 1)
        PartyTranslatorDB[bKey] = Clamp(b, 0, 1)
        if PT.RefreshChatTabs then PT.RefreshChatTabs() end
    end

    local function getFrameColor()
        if ColorPickerFrame.GetColorRGB then
            return ColorPickerFrame:GetColorRGB()
        end
        if ColorPickerFrame.Content and ColorPickerFrame.Content.ColorPicker and ColorPickerFrame.Content.ColorPicker.GetColorRGB then
            return ColorPickerFrame.Content.ColorPicker:GetColorRGB()
        end
        return startR, startG, startB
    end

    local function onChanged()
        local r, g, b = getFrameColor()
        applyColor(r, g, b)
    end

    local function onCancel(previousValues)
        if type(previousValues) == "table" then
            applyColor(
                previousValues.r or previousValues[1] or startR,
                previousValues.g or previousValues[2] or startG,
                previousValues.b or previousValues[3] or startB
            )
        else
            applyColor(startR, startG, startB)
        end
    end

    if ColorPickerFrame.SetupColorPickerAndShow then
        ColorPickerFrame:SetupColorPickerAndShow({
            r = startR,
            g = startG,
            b = startB,
            hasOpacity = false,
            swatchFunc = onChanged,
            cancelFunc = onCancel,
        })
        return true
    end

    ColorPickerFrame.func = onChanged
    ColorPickerFrame.cancelFunc = onCancel
    ColorPickerFrame.hasOpacity = false
    if ColorPickerFrame.SetColorRGB then
        ColorPickerFrame:SetColorRGB(startR, startG, startB)
    end
    ColorPickerFrame.previousValues = { r = startR, g = startG, b = startB }
    if ColorPickerFrame.Show then
        ColorPickerFrame:Show()
        return true
    end
    return false
end

local function CreateOrUpdateTabButton(f, tab, x)
    local button = f.tabButtons[tab.key]
    if not button then
        button = CreateFrame("Button", nil, f.tabBar, "BackdropTemplate")
        button:SetHeight(24)
        button:SetBackdrop({
            bgFile = "Interface/ChatFrame/ChatFrameBackground",
            edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
            edgeSize = 9,
            insets = { left = 2, right = 2, top = 2, bottom = 1 },
        })

        local labelText = button:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        labelText:SetPoint("LEFT", 9, 0)
        labelText:SetPoint("RIGHT", -9, 0)
        labelText:SetJustifyH("LEFT")
        labelText:SetJustifyV("MIDDLE")
        button:SetFontString(labelText)

        local hover = button:CreateTexture(nil, "HIGHLIGHT")
        hover:SetPoint("TOPLEFT", 3, -3)
        hover:SetPoint("BOTTOMRIGHT", -3, 2)
        hover:SetColorTexture(1, 1, 1, 0.07)
        button:SetHighlightTexture(hover)

        -- When active, this thin strip hides the bottom edge and visually
        -- connects the tab to the chat panel instead of looking like a button.
        local join = button:CreateTexture(nil, "OVERLAY")
        join:SetPoint("BOTTOMLEFT", 3, -2)
        join:SetPoint("BOTTOMRIGHT", -3, -2)
        join:SetHeight(4)
        join:Hide()
        button.activeJoin = join

        button:SetScript("OnClick", function()
            if PT.SelectChatTab then PT.SelectChatTab(tab.key, true) end
        end)

        local close
        if tab.kind ~= "GROUP" then
            close = CreateFrame("Button", nil, button)
            close:SetSize(15, 15)
            close:SetPoint("RIGHT", -5, 0)
            close:SetFrameLevel(button:GetFrameLevel() + 4)

            local closeText = close:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            closeText:SetPoint("CENTER", 0, 0)
            closeText:SetText("×")
            closeText:SetTextColor(0.82, 0.82, 0.82, 1)
            close.text = closeText

            local closeHover = close:CreateTexture(nil, "HIGHLIGHT")
            closeHover:SetAllPoints()
            closeHover:SetColorTexture(1, 0.18, 0.18, 0.18)
            close:SetHighlightTexture(closeHover)

            close:SetScript("OnClick", function(self)
                if PT.CloseWhisperTab then PT.CloseWhisperTab(tab.key) end
            end)
            close:SetScript("OnEnter", function(self)
                if self.text then self.text:SetTextColor(1, 0.35, 0.35, 1) end
            end)
            close:SetScript("OnLeave", function(self)
                if self.text then self.text:SetTextColor(0.82, 0.82, 0.82, 1) end
            end)
            button.closeButton = close
            labelText:SetPoint("RIGHT", -22, 0)
        end
        f.tabButtons[tab.key] = button
    end

    local label = tab.name or "Чат"
    if tab.unread then label = "• " .. label end
    local width = tab.kind == "GROUP" and 82 or math.max(96, math.min(160, 58 + #label * 5))
    button:ClearAllPoints()
    button:SetPoint("BOTTOMLEFT", f.tabBar, "BOTTOMLEFT", x, -1)
    button:SetWidth(width)
    button:SetText(label)
    button:Show()
    if tab.kind == "GROUP" and button.closeButton then button.closeButton:Hide() end

    local state = EnsureChatState()
    PT.ApplyTabAppearance(button, state.activeKey == tab.key)
    return width
end

function PT.RefreshChatTabs()
    local f = PT.TranslatorWindow
    if not f or not f.tabBar then return end
    local state = EnsureChatState()

    for _, button in pairs(f.tabButtons or {}) do
        button:Hide()
    end

    local x = 0
    for _, key in ipairs(state.order) do
        local tab = state.tabs[key]
        if tab then
            local width = CreateOrUpdateTabButton(f, tab, x)
            x = x + width + 3
        end
    end
end

function PT.UpdateActiveChatStatus()
    local f = PT.TranslatorWindow
    if not f or not f.status then return end
    local tab = GetActiveTab()
    if not tab then return end
    if tab.kind == "GROUP" then
        local chatType = SmartGroupType()
        local label = CHANNEL_LABELS[chatType] or "Группа"
        f.status:SetText("Enter — отправить: " .. label)
    else
        f.status:SetText("Enter — ЛС: " .. tostring(tab.name or tab.target or "?"))
    end
end

function PT.RenderActiveHistory()
    local f = PT.TranslatorWindow
    if not f or not f.history then return end
    local tab = GetActiveTab()
    f.history:Clear()
    if tab and tab.history then
        for _, line in ipairs(tab.history) do
            f.history:AddMessage(line)
        end
        f.history:ScrollToBottom()
    end
    PT.UpdateActiveChatStatus()
end

function PT.SelectChatTab(key, focusInput)
    if PT.TouchWindowActivity then PT.TouchWindowActivity(false) end
    local state = EnsureChatState()
    local tab = state.tabs[key]
    if not tab then return false end
    state.activeKey = key
    tab.unread = false
    if tab.kind ~= "GROUP" then tab.lastActivity = ChatNow() end
    PT.RefreshChatTabs()
    PT.RenderActiveHistory()
    local f = PT.TranslatorWindow
    if focusInput and f and f.input then f.input:SetFocus() end
    return true
end

local function RemoveTabKeyFromOrder(state, key)
    for i = #state.order, 1, -1 do
        if state.order[i] == key then
            table.remove(state.order, i)
            return
        end
    end
end

function PT.CloseWhisperTab(key)
    local state = EnsureChatState()
    local tab = state.tabs[key]
    if not tab or tab.kind == "GROUP" then return false end

    state.tabs[key] = nil
    RemoveTabKeyFromOrder(state, key)
    if state.activeKey == key then state.activeKey = "group" end

    local f = PT.TranslatorWindow
    if f and f.tabButtons and f.tabButtons[key] then
        f.tabButtons[key]:Hide()
    end
    PT.RefreshChatTabs()
    PT.RenderActiveHistory()
    return true
end

function PT.PruneWhisperTabs()
    local state = EnsureChatState()
    local now = ChatNow()
    local expired = {}
    local f = PT.TranslatorWindow
    local activeText = f and f.input and f.input:GetText() or ""

    for _, key in ipairs(state.order) do
        local tab = state.tabs[key]
        if tab and tab.kind ~= "GROUP" then
            local composing = state.activeKey == key and activeText ~= ""
            if not composing and now - (tab.lastActivity or now) >= WHISPER_TAB_TIMEOUT then
                expired[#expired + 1] = key
            end
        end
    end

    if #expired == 0 then return end
    for _, key in ipairs(expired) do
        local tab = state.tabs[key]
        if tab then
            state.tabs[key] = nil
            RemoveTabKeyFromOrder(state, key)
            if f and f.tabButtons and f.tabButtons[key] then f.tabButtons[key]:Hide() end
        end
        if state.activeKey == key then state.activeKey = "group" end
    end
    PT.RefreshChatTabs()
    PT.RenderActiveHistory()
end

function PT.EnsureWhisperTab(kind, target, author, forceCreate)
    if not forceCreate and not IsCurrentlyGrouped() then return nil end
    if not target and not author then return nil end
    if PT.PruneWhisperTabs then PT.PruneWhisperTabs() end

    local state = EnsureChatState()
    local key = WhisperTabKey(kind, target, author)
    local tab = state.tabs[key]
    if not tab then
        tab = {
            key = key,
            kind = kind == "BN" and "BN" or "WHISPER",
            target = target or author,
            name = DisplayAuthor(author or target),
            history = {},
            lastActivity = ChatNow(),
            unread = true,
        }
        state.tabs[key] = tab
        table.insert(state.order, key)
    else
        tab.target = target or tab.target
        tab.name = DisplayAuthor(author or tab.name)
        tab.lastActivity = ChatNow()
        if state.activeKey ~= key then tab.unread = true end
    end
    PT.RefreshChatTabs()
    return tab
end

local function AppendTabLine(tab, line)
    if not tab or not line then return end
    if PT.TouchWindowActivity then PT.TouchWindowActivity(true) end
    tab.history = tab.history or {}
    tab.history[#tab.history + 1] = line
    if #tab.history > MAX_TAB_HISTORY then table.remove(tab.history, 1) end
    if tab.kind ~= "GROUP" then tab.lastActivity = ChatNow() end

    local state = EnsureChatState()
    local f = PT.TranslatorWindow

    if f and f.isMinimized then
        -- A minimized window cannot visibly render the active history. Treat
        -- every incoming line as unread, including the active GROUP tab.
        tab.unread = true
        PT.ShowMinimizedTranslation(line)
        PT.RefreshChatTabs()
    elseif state.activeKey == tab.key and f and f.history then
        f.history:AddMessage(line)
        f.history:ScrollToBottom()
    else
        if tab.kind ~= "GROUP" then tab.unread = true end
        PT.RefreshChatTabs()
    end
end

function PT.SendTranslatedActive(russian)
    if PT.TouchWindowActivity then PT.TouchWindowActivity(false) end
    if not PartyTranslatorDB or not PartyTranslatorDB.outgoing then return false, "Исходящий перевод выключен." end
    russian = Trim(russian or "")
    if russian == "" then return false, "Введите сообщение." end

    local english, _, translationStatus, partial = PT.TranslateRU(russian)
    if not english then
        if translationStatus == "PARTIAL" and partial then
            return false, "Перевод неполный, отправка заблокирована: " .. partial
        end
        return false, "Не удалось перевести сообщение."
    end
    if #english > 250 then english = english:sub(1, 250) end

    local tab = GetActiveTab()
    if not tab then return false, "Активный чат не найден." end

    local chatType
    if tab.kind == "GROUP" then
        chatType = SmartGroupType()
        if not chatType then return false, "Вы сейчас не в группе/рейде/подземелье." end
        SendChat(english, chatType)
    elseif tab.kind == "BN" then
        if not C_BattleNet or not C_BattleNet.SendWhisper then return false, "Battle.net ЛС недоступны." end
        local ok = C_BattleNet.SendWhisper(tab.target, english)
        if ok == false then return false, "Battle.net не принял сообщение." end
        chatType = "BN_WHISPER"
        tab.lastActivity = ChatNow()
    else
        SendChat(english, "WHISPER", tab.target)
        chatType = "WHISPER"
        tab.lastActivity = ChatNow()
    end

    if PT.LogOutgoing then PT.LogOutgoing(russian, english, chatType, tab.target, tab.key) end
    return true, english
end

function PT.SendTranslatedGroup(russian)
    local state = EnsureChatState()
    local previous = state.activeKey
    state.activeKey = "group"
    local ok, result = PT.SendTranslatedActive(russian)
    state.activeKey = previous
    return ok, result
end

function PT.SendTranslatedReply(russian)
    local tab = GetActiveTab()
    if not tab or tab.kind == "GROUP" then return false, "Выберите вкладку ЛС." end
    return PT.SendTranslatedActive(russian)
end

function PT.AddTranslation(event, author, russian, whisperKind, whisperTarget, forceCreate, originalEnglish, sourceLanguage, translationStatus)
    if not russian or russian == "" then return end
    local f = EnsureTranslatorWindow()
    PT.ApplyWindowAppearance()
    local stamp = date and date("%H:%M") or ""
    local who = DisplayAuthor(author)
    local tab
    local line

    sourceLanguage = tostring(
        sourceLanguage
        or (PT.LastIncomingTranslation and PT.LastIncomingTranslation.language)
        or "EN"
    ):upper()
    translationStatus = tostring(
        translationStatus
        or (PT.LastIncomingTranslation and PT.LastIncomingTranslation.status)
        or "FULL"
    ):upper()

    local incomingReportID = RegisterHistoryReport(
        sourceLanguage .. "_RU",
        originalEnglish or (PT.LastIncomingTranslation and PT.LastIncomingTranslation.original) or "",
        russian,
        translationStatus,
        event
    )
    local reportableRussian = ReportableTranslationText(
        incomingReportID,
        russian,
        GetTranslationColorHex()
    )

    if event == "CHAT_MSG_WHISPER" or event == "CHAT_MSG_BN_WHISPER" then
        tab = PT.EnsureWhisperTab(
            whisperKind or (event == "CHAT_MSG_BN_WHISPER" and "BN" or "WHISPER"),
            whisperTarget or author,
            author,
            forceCreate
        )
        if not tab then return end
        line = string.format(
            "|cff8a8a8a[%s]|r |cffffffff%s:|r |cffb8b8b8[%s]|r %s",
            stamp, who, sourceLanguage, reportableRussian
        )
    else
        local state = EnsureChatState()
        tab = state.tabs.group
        local channel = CHANNEL_LABELS[event] or "Группа"
        line = string.format(
            "|cff8a8a8a[%s] [%s]|r |cffffffff%s:|r |cffb8b8b8[%s]|r %s",
            stamp, channel, who, sourceLanguage, reportableRussian
        )
    end

    AppendTabLine(tab, line)
    if PartyTranslatorDB and PartyTranslatorDB.autoShowWindow and PartyTranslatorDB.separateWindow and not f:IsShown() then
        f:Show()
    end
end


function PT.TestIncomingMessage(kind)
    kind = tostring(kind or "GROUP"):upper()

    local event
    local author
    local whisperKind
    local whisperTarget
    local english

    if kind == "WHISPER" then
        event = "CHAT_MSG_WHISPER"
        author = "Testfriend-TestRealm"
        whisperKind = "WHISPER"
        whisperTarget = author
        english = "in fact ignore that i have tp"
    else
        event = "CHAT_MSG_PARTY"
        author = "Testplayer-TestRealm"
        english = "omw but a summon would be appreciated"
    end

    local russian, testHits, testLanguage, testStatus =
        PT.TranslateIncoming and PT.TranslateIncoming(english) or nil
    if not russian or russian == "" then
        russian = "[нет перевода] " .. english
        testStatus = "FAILED"
    end
    testLanguage = testLanguage or "EN"
    testStatus = testStatus or "FULL"

    -- Make /pt reportlast usable for the simulated incoming message too.
    PT.LastIncomingTranslation = {
        original = english,
        translation = russian,
        status = testStatus,
        language = testLanguage,
        direction = testLanguage .. "_RU",
        hits = tonumber(testHits) or 0,
        context = event .. "_TEST",
    }

    local f = EnsureTranslatorWindow()
    if f and not f:IsShown() then
        -- A settings test should always be visible. If the window is already
        -- minimized, it stays minimized so unread/toast behavior is tested.
        f:Show()
    end

    PT.AddTranslation(
        event,
        author,
        russian,
        whisperKind,
        whisperTarget,
        true,
        english,
        testLanguage,
        testStatus
    )

    if Print then
        local label = kind == "WHISPER" and "ЛС" or "группы"
        Print("тест сообщения " .. label .. " отправлен: |cffffffff" .. english .. "|r")
    end

    return true, english, russian
end

function PT.LogOutgoing(russian, english, chatType, target, tabKey)
    local f = EnsureTranslatorWindow()
    PT.ApplyWindowAppearance()
    local state = EnsureChatState()
    local tab = tabKey and state.tabs[tabKey] or nil
    if not tab then
        if chatType == "WHISPER" or chatType == "BN_WHISPER" then
            tab = state.tabs[state.activeKey]
        else
            tab = state.tabs.group
        end
    end
    if not tab then return end

    local stamp = date and date("%H:%M") or ""
    AppendTabLine(tab, string.format("|cff8a8a8a[%s]|r |cffffffffВы:|r %s", stamp, russian or ""))

    local outgoingReportID = RegisterHistoryReport(
        "RU_EN",
        russian or "",
        english or "",
        "FULL",
        chatType or ""
    )
    local reportableEnglish = ReportableTranslationText(
        outgoingReportID,
        english or "",
        "64d8ff"
    )
    AppendTabLine(tab, "|cff64d8ff[EN]|r " .. reportableEnglish)
end

function PT.OpenTranslatorWindow(focusInput)
    local f = EnsureTranslatorWindow()
    if not f.isMinimized then
        ApplyConfiguredWindowGeometry(f)
    end
    f:Show()
    if not f.isMinimized then
        ApplyConfiguredWindowGeometry(f)
    end
    if f.isMinimized then
        PT.RestoreTranslatorWindow(focusInput ~= false)
        return true
    end
    PT.ApplyWindowControlSettings()
    PT.ApplyWindowAppearance()
    PT.RefreshChatTabs()
    PT.RenderActiveHistory()
    PT.TouchWindowActivity(false)
    if focusInput ~= false then f.input:SetFocus() end
    return true
end

function PT.OpenComposer(text)
    local f = EnsureTranslatorWindow()
    if not f.isMinimized then
        ApplyConfiguredWindowGeometry(f)
    end
    f:Show()
    if not f.isMinimized then
        ApplyConfiguredWindowGeometry(f)
    end
    if f.isMinimized then PT.RestoreTranslatorWindow(false) end
    PT.ApplyWindowControlSettings()
    PT.ApplyWindowAppearance()
    PT.RefreshChatTabs()
    PT.RenderActiveHistory()
    f.input:SetText(text or "")
    f.input:SetFocus()
    PT.TouchWindowActivity(false)
    return true
end

function PT.ClearGroupHistory()
    local state = EnsureChatState()
    local group = state.tabs.group
    if group then group.history = {} end
    if state.activeKey == "group" then PT.RenderActiveHistory() end
end

function PT.ClearTranslationHistory()
    PT.ClearGroupHistory()
end

local function Print(msg)
    DEFAULT_CHAT_FRAME:AddMessage("|cff64d8ffPartyTranslator:|r " .. msg)
end

local function OnSlash(msg)
    msg = Normalize(msg or "")
    local cmd, arg = msg:match("^(%S*)%s*(.-)$")

    if cmd == "on" then
        PartyTranslatorDB.enabled = true
        Print("включён.")
    elseif cmd == "off" then
        PartyTranslatorDB.enabled = false
        Print("выключен.")
    elseif cmd == "original" then
        if arg == "off" then
            PartyTranslatorDB.showOriginal = false
            Print("оригинал скрыт; показывается только перевод.")
        elseif arg == "on" then
            PartyTranslatorDB.showOriginal = true
            Print("оригинал + перевод включены.")
        else
            Print("/pt original on | off")
        end
    elseif cmd == "raid" then
        if arg == "on" then
            PartyTranslatorDB.raid = true
            Print("перевод рейд-чата включён.")
        elseif arg == "off" then
            PartyTranslatorDB.raid = false
            Print("перевод рейд-чата выключен.")
        else
            Print("/pt raid on | off")
        end
    elseif cmd == "whisper" or cmd == "pm" then
        if arg == "on" then
            PartyTranslatorDB.whisper = true
            Print("перевод личных сообщений включён.")
        elseif arg == "off" then
            PartyTranslatorDB.whisper = false
            Print("перевод личных сообщений выключен.")
        else
            Print("/pt whisper on | off")
        end
    elseif cmd == "window" or cmd == "chat" then
        PT.OpenTranslatorWindow(false)
    elseif cmd == "write" or cmd == "w" then
        PT.OpenComposer(arg ~= "" and arg or nil)
    elseif cmd == "send" then
        if arg == "" then
            PT.OpenComposer()
        else
            local ok, result = PT.SendTranslatedActive(arg)
            Print(ok and ("отправлено: |cffffffff" .. result .. "|r") or result)
        end
    elseif cmd == "stats" then
        BuildExpandedWords()
        local s = PT.DictionaryStats or {}
        Print("словарь: " .. tostring(s.directWords or 0) .. " базовых слов + " .. tostring(s.generatedForms or 0) .. " словоформ; фраз: " .. tostring((s.extraPhrases or 0) + 146) .. ".")
    elseif cmd == "reports" then
        local count = PT.GetTranslationReportCount and PT.GetTranslationReportCount() or 0
        Print("жалоб на перевод в памяти: |cffffffff" .. tostring(count) .. "|r.")
        Print("Companion увидит их после /reload или выхода: WTF\\Account\\...\\SavedVariables\\PartyTranslator.lua")
    elseif cmd == "reportlast" then
        local ok, index, duplicateCount = PT.ReportLastIncomingTranslation()
        if ok then
            local suffix = ""
            if tonumber(duplicateCount) and tonumber(duplicateCount) > 1 then
                suffix = " (повтор x" .. tostring(duplicateCount) .. ")"
            end
            Print("последний входящий перевод добавлен как жалоба #" .. tostring(index) .. suffix
                .. ". Для Companion выполните /reload или выйдите из игры.")
        else
            Print(tostring(index or "Не удалось сохранить жалобу."))
        end
    elseif cmd == "clearreports" then
        if arg == "confirm" then
            PartyTranslatorDB.translationReports = {}
            Print("журнал жалоб очищен.")
        else
            Print("для удаления всех жалоб: /pt clearreports confirm")
        end
    elseif cmd == "help-errors" or cmd == "errors-help" then
        if PT.ShowFirstRunHelp then
            PT.ShowFirstRunHelp(true)
        end
    elseif cmd == "verified" then
        local total = 0
        local parts = {}
        for _, direction in ipairs({ "EN_RU", "RU_EN", "FR_RU", "DE_RU", "ES_RU", "IT_RU", "PT_RU", "PL_RU" }) do
            local count = 0
            local bucket = PT.VerifiedCorrections and PT.VerifiedCorrections[direction]
            if type(bucket) == "table" then
                for _ in pairs(bucket) do count = count + 1 end
            end
            if count > 0 then
                total = total + count
                parts[#parts + 1] = direction .. "=" .. tostring(count)
            end
        end
        Print("проверенных исправлений: " .. tostring(total)
            .. (#parts > 0 and (" (" .. table.concat(parts, ", ") .. ")") or ""))
        if PT.VerifiedCorrectionsMeta
            and PT.VerifiedCorrectionsMeta.generatedAt
            and PT.VerifiedCorrectionsMeta.generatedAt ~= "" then
            Print("экспорт Companion: " .. tostring(PT.VerifiedCorrectionsMeta.generatedAt))
        end
    elseif cmd == "lang" then
        if arg == "" then
            Print("/pt lang <текст> — определить язык и показать локальный перевод")
        else
            local languageCode, confidence = PT.DetectLanguage(arg)
            local russian, hits, translatedLanguage, status = PT.TranslateIncoming(arg)
            local shownCode = translatedLanguage or languageCode or "UNKNOWN"
            Print("язык: |cffffffff[" .. tostring(shownCode) .. "] "
                .. tostring(PT.GetLanguageName(shownCode)) .. "|r"
                .. "  score=" .. tostring(confidence or 0))
            if russian then
                Print("|cffffffff" .. arg .. "|r -> "
                    .. ColorTranslation(russian)
                    .. "  [" .. tostring(status or "?")
                    .. ", hits=" .. tostring(hits or 0) .. "]")
            else
                Print("локального перевода пока нет; оригинал оставлен без изменений.")
            end
        end
    elseif cmd == "rucheck" or cmd == "version" then
        Print("|cffffd100RU engine patch: " .. RU_ENGINE_VERSION .. "|r")
        Print("GeneralDictionary: EN words=" .. tostring(PT.GeneralWordsCount or 0)
            .. ", RU words=" .. tostring(PT.GeneralRUWordsCount or 0))

        local samples = {
            "привет друзья, можете поговорить со мной",
            "помогите сумануть танка",
            "спасибо большое",
            "не пульте пока я иду",
            "танк можешь забрать босса",
            "танк можешь забрать ада",
            "танк можешь забрать адов",
            "дайте бл на следующем пулле",
            "забери адда после пулла",
            "пуль босса во второй фазе",
            "фокус босса потом убей аддов",
            "сначала фокус босса потом стакайтесь",
            "пуль через 5 сек",
            "если танк умрет фокус босса",
            "когда танк будет готов пуль адда",
            "танк можешь забрать босса после пулла",
            "народ как у вас дела? что нового? давайте по быстрому пройдем.",
            "давайте закроем до конца",
            "давайте пройдем до конца",

            -- Semantic parser / morphology / word-order regression tests.
            "покажите класс",
            "покажи класс",
            "класс покажите",
            "покажите илвл",
            "можете показать маршрут",
            "пожалуйста покажите ключ",
            "давайте пройдем быстро",
            "давайте быстро пройдем",
            "быстро давайте пройдем",
            "давайте побыстрее пройдем",
            "давайте ключ быстро закроем",
            "ключ давайте закроем быстро",

            -- AFK/status semantic regression tests.
            "5 мин афк, я курить",
            "афк 5 мин, покурить",
            "я афк на 5 минут, пойду курить",
            "5 минут афк",

            -- Pronoun / case regression tests.
            "всех",
            "всем",
            "все",
        }

        for _, sample in ipairs(samples) do
            local en, hits, translationStatus, partial = PT.TranslateRU(sample)
            local shown = en or partial or "(нет перевода)"
            Print("|cffffffff" .. sample .. "|r -> |cff40ff40" .. tostring(shown)
                .. "|r  [" .. tostring(translationStatus or "?")
                .. ", hits=" .. tostring(hits or 0) .. "]")
        end

        Print("|cffffd100Language detection / foreign→RU tests:|r")
        local languageSamples = {
            "peux-tu m'invoquer ?",
            "kannst du bitte warten?",
            "puedes esperar por favor?",
            "puoi aspettare per favore?",
            "pode esperar por favor?",
            "możesz proszę czekać?",
        }
        for _, sample in ipairs(languageSamples) do
            local detected, confidence = PT.DetectLanguage(sample)
            local ru, hits, lang, status = PT.TranslateIncoming(sample)
            Print("|cffffffff[" .. tostring(lang or detected or "?") .. "] "
                .. sample .. "|r -> |cff40ff40"
                .. tostring(ru or "(нет перевода)")
                .. "|r  [" .. tostring(status or "?")
                .. ", score=" .. tostring(confidence or 0)
                .. ", hits=" .. tostring(hits or 0) .. "]")
        end

        Print("|cffffd100EN→RU semantic tests:|r")
        local enSamples = {
            "omw but a summon would be appreciated",
            "in fact ignore that i have tp",
            "nvm i have tp",
            "i could use a summon",
        }
        for _, sample in ipairs(enSamples) do
            local ru, hits = PT.Translate(sample)
            Print("|cffffffff" .. sample .. "|r -> |cff40ff40"
                .. tostring(ru or "(нет перевода)")
                .. "|r  [hits=" .. tostring(hits or 0) .. "]")
        end
    elseif cmd == "color" then
        local wanted = tonumber(arg)
        if wanted and PT.TranslationColors[wanted] then
            PartyTranslatorDB.translationColor = wanted
            local c = PT.TranslationColors[wanted]
            Print("цвет перевода: " .. "|cff" .. c.hex .. c.name .. "|r.")
        else
            Print("цвет выбирается в Esc → Настройки → Модификации → PartyTranslator.")
        end
    elseif cmd == "settings" or cmd == "config" or cmd == "options" then
        if PT.OpenSettings and PT.OpenSettings() then
            return
        end
        Print("не удалось открыть настройки. Откройте Esc → Настройки → Модификации → PartyTranslator.")
    elseif cmd == "test" then
        local samples = {
            "ty, need sum pls",
            "wait for tank, healer oom",
            "don't pull yet, lust on boss",
            "kick next and use defensive",
            "stack on me then move left",
        }
        for _, sample in ipairs(samples) do
            local ru = PT.Translate(sample) or "(нет перевода)"
            Print("|cffffffff" .. sample .. "|r  ->  " .. ColorTranslation(ru))
        end
    else
        Print("локальный EN↔RU переводчик для группы.")
        Print("/pt on | off — включить/выключить")
        Print("/pt original on | off — оригинал + перевод / только перевод")
        Print("/pt raid on | off — добавить/убрать рейд-чат")
        Print("/pt whisper on | off — личные сообщения")
        Print("/pt window — открыть отдельное окно переводчика")
        Print("/pt write — открыть окно и перейти к RU→EN вводу")
        Print("/pt send <текст> — перевести RU→EN и отправить в активную вкладку")
        Print("/pt stats — размер словаря")
        Print("/pt reports — количество сохранённых жалоб")
        Print("ПКМ по переводу в истории → Ошибка перевода")
        Print("/pt reportlast — пожаловаться на последний входящий перевод")
        Print("/pt clearreports confirm — очистить журнал жалоб")
        Print("/pt verified — показать исправления Companion")
        Print("/pt color 1-10 — выбрать цвет перевода")
        Print("/pt settings — открыть настройки в Модификациях")
        Print("/pt test — тест переводов")
    end
end

local frame = CreateFrame("Frame")
frame:RegisterEvent("ADDON_LOADED")
frame:SetScript("OnEvent", function(_, event, addon)
    if event == "ADDON_LOADED" then
        if addon ~= ADDON_NAME then return end

        InitDB()
        BuildExpandedWords()
        if PT.RegisterSettings then
            PT.RegisterSettings()
        end
        if PartyTranslatorDB.separateWindow and PartyTranslatorDB.autoShowWindow then
            PT.OpenTranslatorWindow(false)

            -- Blizzard may finish resolving UI layout/scale after ADDON_LOADED.
            -- Reapply the saved geometry on the next frame so a fresh install
            -- cannot remain visually stuck at a transient tiny size.
            if C_Timer and C_Timer.After then
                C_Timer.After(0, function()
                    local window = PT.TranslatorWindow
                    if window and window:IsShown() and not window.isMinimized then
                        ApplyConfiguredWindowGeometry(window)
                        PT.ApplyWindowControlSettings()
                        PT.ApplyWindowAppearance()
                        if PT.RefreshChatTabs then PT.RefreshChatTabs() end
                        if PT.RenderActiveHistory then PT.RenderActiveHistory() end
                    end
                end)
            end
        end

        for _, chatEvent in ipairs(EVENTS) do
            ChatFrame_AddMessageEventFilter(chatEvent, ChatFilter)
        end

        SLASH_PARTYTRANSLATOR1 = "/pt"
        SLASH_PARTYTRANSLATOR2 = "/partytranslator"
        SlashCmdList.PARTYTRANSLATOR = OnSlash

        SLASH_PARTYTRANSLATOROUT1 = "/tr"
        SLASH_PARTYTRANSLATOROUT2 = "/translate"
        SlashCmdList.PARTYTRANSLATOROUT = function(text)
            text = Trim(text or "")
            if text == "" then
                PT.OpenComposer()
                return
            end
            local ok, result = PT.SendTranslatedActive(text)
            Print(ok and ("отправлено: |cffffffff" .. result .. "|r") or result)
        end

        PT.WasGrouped = IsCurrentlyGrouped()
        frame:RegisterEvent("GROUP_ROSTER_UPDATE")

        Print("загружен. Отдельное окно: /pt window. Настройки: Esc → Настройки → Модификации → PartyTranslator.")

        -- Show the user guide once. A small delay lets Blizzard finish opening
        -- the normal UI and avoids competing with startup layout work.
        if C_Timer and C_Timer.After then
            C_Timer.After(0.8, function()
                if PT.ShowFirstRunHelp then PT.ShowFirstRunHelp(false) end
            end)
        elseif PT.ShowFirstRunHelp then
            PT.ShowFirstRunHelp(false)
        end
        return
    end

    if event == "GROUP_ROSTER_UPDATE" then
        local grouped = IsCurrentlyGrouped()
        if PT.WasGrouped and not grouped then
            PT.ClearGroupHistory()
        end
        PT.WasGrouped = grouped
    end
end)
