using System.Diagnostics;
using System.Globalization;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Text;
using HellionChat.Code;
using HellionChat.GameFunctions;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface;
using Dalamud.Interface.Style;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Memory;
using FFXIVClientStructs.FFXIV.Client.UI;
using Dalamud.Bindings.ImGui;
using Lumina.Excel.Sheets;
using Lumina.Extensions;

namespace HellionChat.Ui;

public sealed class ChatLogWindow : Window
{
    private const string ChatChannelPicker = "chat-channel-picker";
    private const string AutoCompleteId = "##chat2-autocomplete";

    private const ImGuiInputTextFlags InputFlags = ImGuiInputTextFlags.CallbackAlways | ImGuiInputTextFlags.CallbackCharFilter |
                                                   ImGuiInputTextFlags.CallbackCompletion | ImGuiInputTextFlags.CallbackHistory;

    internal Plugin Plugin { get; }

    private readonly CommandWrapper _clearHellionCommand;
    private readonly CommandWrapper _hellionCommand;

    internal bool ScreenshotMode;
    private string Salt { get; }

    internal Vector4 DefaultText { get; set; }

    internal bool FocusedPreview;
    internal bool Activate;
    internal bool InputFocused { get; private set; }
    private int ActivatePos = -1;
    internal string Chat = string.Empty;
    // Hellion Chat — v0.6.0 input history was extracted into
    // InputHistoryService so pop-out windows with their own ChatInputBar
    // share the same Up/Down history with the main window. The cursor
    // stays window-local because each window navigates independently.
    private int InputBacklogIdx = -1;
    public bool TellSpecial;
    private readonly Stopwatch LastResize = new();
    private AutoCompleteInfo? AutoCompleteInfo;
    private bool AutoCompleteOpen;
    private List<AutoTranslateEntry>? AutoCompleteList;
    private bool FixCursor;
    private int AutoCompleteSelection;
    private bool AutoCompleteShouldScroll;

    // Used to detect channel changes for the webinterface
    public Chunk[] PreviousChannel = [];

    public int CursorPos;

    public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
    public Vector2 LastWindowSize { get; private set; } = Vector2.Zero;

    // Window position recovery: guards against off-screen positions after a
    // display layout change (monitor disconnected, resolution changed). On
    // the first draw after plugin load we run a one-shot bounds check to see
    // whether the stored position still overlaps any visible viewport area.
    // The manual reset button in the settings forces the position regardless.
    private bool DidOnLoadBoundsCheck;
    internal bool RequestPositionReset { get; set; }

    public unsafe ImGuiViewport* LastViewport;
    private bool WasDocked;

    public PayloadHandler PayloadHandler { get; }
    internal Lender<PayloadHandler> HandlerLender { get; }
    private Dictionary<string, ChatType> TextCommandChannels { get; } = new();
    private Dictionary<string, TextCommand> AllCommands { get; } = [];

    private const uint ChatOpenSfx = 35u;
    private const uint ChatCloseSfx = 3u;
    private bool PlayedClosingSound = true;
    private bool DrewThisFrame;

    private long FrameTime; // set every frame
    internal long LastActivityTime = Environment.TickCount64;

    internal ChatLogWindow(Plugin plugin) : base($"{Plugin.PluginName}###chat2")
    {
        Plugin = plugin;
        Salt = new Random().Next().ToString();

        Size = new Vector2(500, 250);
        SizeCondition = ImGuiCond.FirstUseEver;

        PositionCondition = ImGuiCond.Always;

        IsOpen = true;
        RespectCloseHotkey = false;
        DisableWindowSounds = true;

        PayloadHandler = new PayloadHandler(this);
        HandlerLender = new Lender<PayloadHandler>(() => new PayloadHandler(this));

        SetUpTextCommandChannels();
        SetUpAllCommands();

        // Cache the registered wrapper instances so Dispose can detach the same
        // event objects the constructor attached to, without going through
        // Register() again (which would re-create the wrapper if the command
        // happened to be missing from the dictionary).
        _clearHellionCommand = Plugin.Commands.Register("/clearhellion", "Clear the Hellion Chat log");
        _hellionCommand = Plugin.Commands.Register("/hellion");
        _clearHellionCommand.Execute += ClearLog;
        _hellionCommand.Execute += ToggleChat;

        Plugin.ClientState.Login += Login;
        Plugin.ClientState.Logout += Logout;

        Plugin.AddonLifecycle.RegisterListener(AddonEvent.PostUpdate, "ItemDetail", PayloadHandler.MoveTooltip);
        Plugin.AddonLifecycle.RegisterListener(AddonEvent.PostUpdate, "ActionDetail", PayloadHandler.MoveTooltip);
    }

    public void Dispose()
    {
        Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "ItemDetail", PayloadHandler.MoveTooltip);
        Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "ActionDetail", PayloadHandler.MoveTooltip);
        Plugin.ClientState.Logout -= Logout;
        Plugin.ClientState.Login -= Login;
        _hellionCommand.Execute -= ToggleChat;
        _clearHellionCommand.Execute -= ClearLog;
    }

    private void Logout(int _, int __)
    {
        Plugin.MessageManager.ClearAllTabs();
    }

    private void Login()
    {
        Plugin.MessageManager.FilterAllTabsAsync();
    }

    internal unsafe void Activated(ChatActivatedArgs args)
    {
        TellSpecial = args.TellSpecial;

        Activate = true;
        PlayedClosingSound = false;
        if (Plugin.Config.PlaySounds)
            UIGlobals.PlaySoundEffect(ChatOpenSfx);

        // Don't set the channel or text content when activating a disabled tab.
        if (Plugin.CurrentTab.InputDisabled)
        {
            // The closing sound would've been immediately played in this case.
            PlayedClosingSound = true;
            return;
        }

        if (args.AddIfNotPresent != null && !Chat.Contains(args.AddIfNotPresent))
            Chat += args.AddIfNotPresent;

        if (args.Input != null)
            Chat += args.Input;

        var (info, reason, target) = (args.ChannelSwitchInfo, args.TellReason, args.TellTarget);

        if (info.Channel != null)
        {
            var targetChannel = info.Channel;
            if (info.Channel is InputChannel.Tell)
            {
                if (info.Rotate != RotateMode.None)
                {
                    var idx = Plugin.CurrentTab.CurrentChannel.TempChannel != InputChannel.Tell
                        ? 0 : info.Rotate == RotateMode.Reverse
                            ? -1 : 1;

                    var tellInfo = Plugin.Functions.Chat.GetTellHistoryInfo(idx);
                    if (tellInfo != null && reason != null)
                        Plugin.CurrentTab.CurrentChannel.TempTellTarget = new TellTarget(tellInfo.Name, (ushort) tellInfo.World, tellInfo.ContentId, reason.Value);
                }
                else
                {
                    Plugin.CurrentTab.CurrentChannel.TellTarget = null;
                    if (target != null)
                    {
                        if (info.Permanent)
                        {
                            Plugin.CurrentTab.CurrentChannel.TellTarget = target;
                        }
                        else
                        {
                            Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
                            Plugin.CurrentTab.CurrentChannel.TempTellTarget = target;
                        }
                    }
                }
            }
            else
            {
                Plugin.CurrentTab.CurrentChannel.TellTarget = null;
            }

            if (info.Channel is InputChannel.Linkshell1 or InputChannel.CrossLinkshell1 && info.Rotate != RotateMode.None)
            {
                var module = UIModule.Instance();

                // If any of these operations fail, do nothing.
                if (info.Permanent)
                {
                    // Rotate using the game's code.
                    if (info.Channel == InputChannel.Linkshell1)
                    {
                        GameFunctions.Chat.RotateLinkshellHistory(info.Rotate);
                        targetChannel = info.Channel + (uint)module->LinkshellCycle;
                    }
                    else
                    {
                        GameFunctions.Chat.RotateCrossLinkshellHistory(info.Rotate);
                        targetChannel = info.Channel + (uint)module->CrossWorldLinkshellCycle;
                    }
                }
                else
                {
                    targetChannel = GameFunctions.Chat.ResolveTempInputChannel(Plugin.CurrentTab.CurrentChannel.TempChannel, info.Channel.Value, info.Rotate);
                }
            }

            if (targetChannel == null || !GameFunctions.Chat.ValidAnyLinkshell(targetChannel.Value))
            {
                Plugin.Log.Warning($"Channel was set to an invalid value '{targetChannel}', ignoring");
                return;
            }

            if (info.Permanent)
            {
                SetChannel(targetChannel);
            }
            else
            {
                Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
                Plugin.CurrentTab.CurrentChannel.TempChannel = targetChannel.Value;
            }
        }

        if (info.Text != null && Chat.Length == 0)
            Chat = info.Text;
    }

    private bool IsValidCommand(string command)
    {
        return Plugin.CommandManager.Commands.ContainsKey(command) || AllCommands.ContainsKey(command);
    }

    private void ClearLog(string command, string arguments)
    {
        switch (arguments)
        {
            case "all":
                Plugin.MessageManager.ClearAllTabs();
                break;
            case "help":
                Plugin.ChatGui.Print("- /clearlog2: clears the active tab's log");
                Plugin.ChatGui.Print("- /clearlog2 all: clears all tabs' logs and the global history");
                Plugin.ChatGui.Print("- /clearlog2 help: shows this help");
                break;
            default:
                if (Plugin.LastTab > -1 && Plugin.LastTab < Plugin.Config.Tabs.Count)
                    Plugin.Config.Tabs[Plugin.LastTab].Clear();
                break;
        }
    }

    private void ToggleChat(string _, string arguments)
    {
        switch (arguments)
        {
            case "hide":
                CurrentHideState = HideState.User;
                Plugin.Log.Verbose("HideState: → User (chat hide command)");
                break;
            case "show":
                CurrentHideState = HideState.None;
                Plugin.Log.Verbose("HideState: → None (chat show command)");
                break;
            case "toggle":
                CurrentHideState = CurrentHideState switch
                {
                    HideState.User or HideState.CutsceneOverride => HideState.None,
                    HideState.Cutscene => HideState.CutsceneOverride,
                    HideState.None => HideState.User,
                    _ => CurrentHideState,
                };
                Plugin.Log.Verbose($"HideState: → {CurrentHideState} (chat toggle command)");
                break;
        }
    }

    private void SetUpTextCommandChannels()
    {
        TextCommandChannels.Clear();

        foreach (var input in Enum.GetValues<InputChannel>())
        {
            var commands = input.TextCommands();
            if (commands == null)
                continue;

            var type = input.ToChatType();
            foreach (var command in commands)
                AddTextCommandChannel(command, type);
        }

        if (Sheets.TextCommandSheet.TryGetRow(116, out var row))
            AddTextCommandChannel(row, ChatType.Echo);
    }

    private void AddTextCommandChannel(TextCommand command, ChatType type)
    {
        TextCommandChannels[command.Command.ExtractText()] = type;
        TextCommandChannels[command.ShortCommand.ExtractText()] = type;
        TextCommandChannels[command.Alias.ExtractText()] = type;
        TextCommandChannels[command.ShortAlias.ExtractText()] = type;
    }

    private void SetUpAllCommands()
    {
        foreach (var command in Sheets.TextCommandSheet)
        {
            if (!command.Command.IsEmpty)
                AllCommands.TryAdd(command.Command.ToString(), command);

            if (!command.ShortCommand.IsEmpty)
                AllCommands.TryAdd(command.ShortCommand.ToString(), command);

            if (!command.Alias.IsEmpty)
                AllCommands.TryAdd(command.Alias.ToString(), command);

            if (!command.ShortAlias.IsEmpty)
                AllCommands.TryAdd(command.ShortAlias.ToString(), command);
        }
    }

    private void AddBacklog(string message)
    {
        // v0.6.0 — delegates to the shared InputHistoryService so pop-out
        // ChatInputBar instances see the same history. Move-to-newest
        // deduplication lives inside the service.
        InputHistoryService.Push(message);
    }

    private float GetRemainingHeightForMessageLog()
    {
        var lineHeight = ImGui.CalcTextSize("A").Y;
        var height = ImGui.GetContentRegionAvail().Y - lineHeight * 2 - ImGui.GetStyle().ItemSpacing.Y - ImGui.GetStyle().FramePadding.Y * 2;

        if (Plugin.Config.PreviewPosition is PreviewPosition.Inside)
            height -= Plugin.InputPreview.PreviewHeight;

        // Hellion Chat v0.6.1 — Header-Toolbar rendert auf Window-Ebene über
        // einem horizontalen Layout-Pfad und wird von GetContentRegionAvail
        // hier drin NICHT automatisch berücksichtigt, daher expliziter Abzug.
        // Banner dagegen rendert in DrawChatLog VOR diesem ganzen Block und
        // ImGui zieht seine Höhe automatisch von GetContentRegionAvail ab,
        // weil der Cursor schon weiter unten steht — kein eigener Abzug.
        height -= ImGui.GetFrameHeightWithSpacing();

        return height;
    }

    internal void ChangeTab(int index) {
        Plugin.WantedTab = index;
        LastActivityTime = FrameTime;
    }

    internal void ChangeTabDelta(int offset)
    {
        var newIndex = (Plugin.LastTab + offset) % Plugin.Config.Tabs.Count;
        while (newIndex < 0)
            newIndex += Plugin.Config.Tabs.Count;
        ChangeTab(newIndex);
    }

    private void TabSwitched(Tab newTab, Tab previousTab)
    {
        // Use the fixed channel if set by the user, or set it to the current tabs channel if this tab wasn't accessed before
        if (newTab.Channel is not null)
            newTab.CurrentChannel.Channel = newTab.Channel.Value;
        else if (newTab.CurrentChannel.Channel is InputChannel.Invalid)
            newTab.CurrentChannel = previousTab.CurrentChannel;

        SetChannel(newTab.CurrentChannel.Channel);
    }

    private enum HideState
    {
        None,
        Cutscene,
        CutsceneOverride,
        User,
        Battle
    }

    private HideState CurrentHideState = HideState.None;

    public bool IsHidden;
    public void HideStateCheck()
    {
        // if the chat has no hide state set, and the player has entered battle, we hide chat if they have configured it
        if (Plugin.Config.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
        {
            CurrentHideState = HideState.Battle;
            Plugin.Log.Verbose("HideState: None → Battle");
        }

        // If the chat is hidden because of battle, we reset it here
        if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
        {
            CurrentHideState = HideState.None;
            Plugin.Log.Verbose("HideState: Battle → None");
        }

        // if the chat has no hide state and in a cutscene, set the hide state to cutscene
        if (Plugin.Config.HideDuringCutscenes && CurrentHideState == HideState.None && (Plugin.CutsceneActive || Plugin.GposeActive))
        {
            if (Plugin.Functions.Chat.CheckHideFlags())
            {
                CurrentHideState = HideState.Cutscene;
                Plugin.Log.Verbose("HideState: None → Cutscene");
            }
        }

        // if the chat is hidden because of a cutscene and no longer in a cutscene, set the hide state to none
        if (CurrentHideState is HideState.Cutscene or HideState.CutsceneOverride && !Plugin.CutsceneActive && !Plugin.GposeActive)
        {
            Plugin.Log.Verbose($"HideState: {CurrentHideState} → None (cutscene/gpose ended)");
            CurrentHideState = HideState.None;
        }

        // if the chat is hidden because of a cutscene and the chat has been activated, show chat
        if (CurrentHideState == HideState.Cutscene && Activate)
        {
            CurrentHideState = HideState.CutsceneOverride;
            Plugin.Log.Verbose("HideState: Cutscene → CutsceneOverride (user activate)");
        }

        // if the user hid the chat and is now activating chat, reset the hide state
        if (CurrentHideState == HideState.User && Activate)
        {
            CurrentHideState = HideState.None;
            Plugin.Log.Verbose("HideState: User → None (activate)");
        }

        if (CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle || (Plugin.Config.HideWhenNotLoggedIn && !Plugin.ClientState.IsLoggedIn))
        {
            IsHidden = true;
            return;
        }

        IsHidden = false;
    }

    internal void BeginFrame()
    {
        DrewThisFrame = false;
    }

    internal void FinalizeFrame()
    {
        if (!DrewThisFrame)
            InputFocused = false;
    }

    public override unsafe void PreOpenCheck()
    {
        Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoFocusOnAppearing;
        if (!Plugin.Config.CanMove)
            Flags |= ImGuiWindowFlags.NoMove;

        if (!Plugin.Config.CanResize)
            Flags |= ImGuiWindowFlags.NoResize;

        if (!Plugin.Config.ShowTitleBar)
            Flags |= ImGuiWindowFlags.NoTitleBar;

        if (LastViewport == ImGuiHelpers.MainViewport.Handle && !WasDocked)
            BgAlpha = Plugin.Config.WindowOpacity;

        LastViewport = ImGui.GetWindowViewport().Handle;
        WasDocked = ImGui.IsWindowDocked();
    }

    public override bool DrawConditions()
    {
        FrameTime = Environment.TickCount64;
        if (IsHidden)
            return false;

        if (!Plugin.Config.HideWhenInactive || (!Plugin.Config.InactivityHideActiveDuringBattle && Plugin.InBattle) || Activate)
        {
            LastActivityTime = FrameTime;
            return true;
        }

        var currentTab = Plugin.CurrentTab; // local to avoid calling the getter repeatedly
        var lastActivityTime = Plugin.Config.Tabs
            .Where(tab => !tab.PopOut && (tab.UnhideOnActivity || tab == currentTab))
            .Select(tab => tab.LastActivity)
            .Append(LastActivityTime)
            .Max();
        return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
    }

    public override void PreDraw()
    {
        if (Plugin.Config.KeepInputFocus && Activate)
            ImGui.SetWindowFocus(WindowName);

        // Hellion Chat v1.1.0+ — Theme-Engine ist Source-of-Truth, kein
        // zusätzlicher Dalamud-StyleModel-Override mehr pro Window. Plugin.Draw
        // pusht das aktive Hellion-Theme global; ChatLogWindow zeichnet sich
        // damit konsistent zu Settings/Pop-Out/Wizard. Wer den Upstream-Look
        // will, wählt das Built-In-Theme "Chat 2 Klassik" in Settings → Themes.
    }

    public override void PostDraw()
    {
        // Set Activate to false after draw to avoid repeatedly trying to focus
        // the text input in a tab with input disabled. The usual way that
        // Activate gets disabled is via the text input callback, but that
        // doesn't get called if the input is disabled.
        if (Plugin.CurrentTab.InputDisabled)
            Activate = false;
    }

    public override void OnClose()
    {
        // We force the main log to be always open
        IsOpen = true;
    }

    public override void Draw()
    {
        DrewThisFrame = true;
        try
        {
            DrawChatLog();
            AddPopOutsToDraw();
            DrawAutoComplete();
        }
        catch (Exception ex)
        {
            Plugin.Log.Error(ex, "Error drawing Chat Log window");
            // Prevent recurring draw failures from constantly trying to grab
            // input focus, which breaks every other ImGui window.
            Activate = false;
        }
    }

    private static bool IsChatMode => Plugin.Config.PreviewPosition is PreviewPosition.Inside or PreviewPosition.Tooltip;
    private unsafe void DrawChatLog()
    {
        // Position change has applied, so we set it to null again
        Position = null;

        var currentSize = ImGui.GetWindowSize();
        var resized = LastWindowSize != currentSize;
        LastWindowSize = currentSize;
        LastWindowPos = ImGui.GetWindowPos();

        // Window position recovery. Manual reset takes precedence and snaps
        // the window to the safe default unconditionally; the one-shot
        // on-load check only fires when the persisted position has no
        // overlap with any visible viewport area.
        if (RequestPositionReset)
        {
            RequestPositionReset = false;
            DidOnLoadBoundsCheck = true;
            ApplySafeDefaultPosition("manual-reset");
        }
        else if (!DidOnLoadBoundsCheck)
        {
            DidOnLoadBoundsCheck = true;
            EnsureWindowOnScreen("on-load");
        }

        if (resized)
            LastResize.Restart();

        LastViewport = ImGui.GetWindowViewport().Handle;
        WasDocked = ImGui.IsWindowDocked();

        if (IsChatMode && Plugin.InputPreview.IsDrawable)
            Plugin.InputPreview.CalculatePreview();

        // Hellion Chat v0.6.1 — render the one-time hint banner first so it
        // sits above the tab area / sidebar in full window width. ImGui's
        // GetContentRegionAvail subtracts its height automatically because the
        // cursor advances past it before the message log calls
        // GetRemainingHeightForMessageLog, so we don't track the height here.
        DrawV061HintBannerIfNeeded();

        if (Plugin.Config.SidebarTabView)
            DrawTabSidebar();
        else
            DrawTabBar();

        var activeTab = Plugin.CurrentTab;

        // This tab has a fixed channel, so we force this channel to be always set as current
        if (activeTab.Channel is not null)
            activeTab.CurrentChannel.SetChannel(activeTab.Channel.Value);

        if (Plugin.Config.PreviewPosition is PreviewPosition.Inside && Plugin.InputPreview.IsDrawable)
            Plugin.InputPreview.DrawPreview();

        using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
        {
            DrawChannelName(activeTab);
        }

        // v1.0.2 — compute inputColour up front so the channel selector button
        // can also tint with it (existing input-text push remains below).
        var inputType = activeTab.CurrentChannel.UseTempChannel ? activeTab.CurrentChannel.TempChannel.ToChatType() : activeTab.CurrentChannel.Channel.ToChatType();
        var isCommand = Chat.Trim().StartsWith('/');
        if (isCommand)
        {
            var command = Chat.Split(' ')[0];
            if (TextCommandChannels.TryGetValue(command, out var channel))
                inputType = channel;

            if (!IsValidCommand(command))
                inputType = ChatType.Error;
        }

        var inputColour = Plugin.Config.ChatColours.TryGetValue(inputType, out var inputCol) ? inputCol : inputType.DefaultColor();

        if (!isCommand && Plugin.ExtraChat.ChannelOverride is var (_, overrideColour))
            inputColour = overrideColour;

        if (isCommand && Plugin.ExtraChat.ChannelCommandColours.TryGetValue(Chat.Split(' ')[0], out var ecColour))
            inputColour = ecColour;

        var beforeIcon = ImGui.GetCursorPos();

        var tintSelector = Plugin.Config.ColorSelectedInputChannelButton && inputColour.HasValue;
        var selectorAbgr = tintSelector ? ColourUtil.RgbaToAbgr(inputColour!.Value) : 0u;

        using (ImRaii.PushColor(ImGuiCol.Button, selectorAbgr, tintSelector))
        using (ImRaii.PushColor(ImGuiCol.ButtonHovered, ColourUtil.AdjustBrightness(selectorAbgr, 1.15f), tintSelector))
        using (ImRaii.PushColor(ImGuiCol.ButtonActive, ColourUtil.AdjustBrightness(selectorAbgr, 0.85f), tintSelector))
        {
            if (ImGuiUtil.IconButton(FontAwesomeIcon.Comment) && activeTab.Channel is null)
                ImGui.OpenPopup(ChatChannelPicker);
        }

        if (activeTab.Channel is not null && ImGui.IsItemHovered())
            ImGuiUtil.Tooltip(Language.ChatLog_SwitcherDisabled);

        using (var popup = ImRaii.Popup(ChatChannelPicker))
        {
            if (popup)
            {
                var channels = GetValidChannels();
                foreach (var (name, channel) in channels)
                    if (ImGui.Selectable(name))
                        SetChannel(channel);
            }
        }

        ImGui.SameLine();
        var afterIcon = ImGui.GetCursorPos();

        var buttonWidth = afterIcon.X - beforeIcon.X;
        var showNovice = Plugin.Config.ShowNoviceNetwork && GameFunctions.GameFunctions.IsMentor();
        var buttonsRight = (showNovice ? 1 : 0) + (Plugin.Config.ShowHideButton ? 1 : 0);
        var inputWidth = ImGui.GetContentRegionAvail().X - buttonWidth * (1 + buttonsRight);

        var normalColor = ImGui.GetColorU32(ImGuiCol.Text);
        var push = inputColour != null;
        using (ImRaii.PushColor(ImGuiCol.Text, push ? ColourUtil.RgbaToAbgr(inputColour!.Value) : 0, push))
        {
            var isChatEnabled = activeTab is { InputDisabled: false };
            if (isChatEnabled && (Activate || FocusedPreview))
            {
                FocusedPreview = false;
                ImGui.SetKeyboardFocusHere();
            }

            var chatCopy = Chat;
            using (ImRaii.Disabled(!isChatEnabled))
            {
                var flags = InputFlags | (!isChatEnabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None);
                ImGui.SetNextItemWidth(inputWidth);
                ImGui.InputTextWithHint("##chat2-input", isChatEnabled ? "": Language.ChatLog_DisabledInput, ref Chat, 500, flags, Callback);
            }
            var inputActive = ImGui.IsItemActive();
            InputFocused = isChatEnabled && inputActive;

            var tooltipDraw = Plugin.Config.PreviewPosition is PreviewPosition.Tooltip && Plugin.InputPreview.IsDrawable;
            if (tooltipDraw && ImGui.IsItemHovered())
            {
                ImGui.SetNextWindowSize(new Vector2(500 * ImGuiHelpers.GlobalScale, -1));
                using var tooltip = ImRaii.Tooltip();
                Plugin.InputPreview.DrawPreview();
            }

            if (ImGui.IsItemDeactivated())
            {
                if (ImGui.IsKeyDown(ImGuiKey.Escape))
                {
                    Chat = chatCopy;

                    if (activeTab.CurrentChannel.UseTempChannel)
                    {
                        activeTab.CurrentChannel.ResetTempChannel();
                        SetChannel(activeTab.CurrentChannel.Channel);
                    }
                }

                if (ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter))
                {
                    Plugin.CommandHelpWindow.IsOpen = false;
                    SendChatBox(activeTab);

                    if (activeTab.CurrentChannel.UseTempChannel)
                    {
                        activeTab.CurrentChannel.ResetTempChannel();
                        SetChannel(activeTab.CurrentChannel.Channel);
                    }
                }
            }

            // Process keybinds that have modifiers while the chat is focused.
            if (inputActive)
            {
                Plugin.Functions.KeybindManager.HandleKeybinds(KeyboardSource.ImGui, true, true);
                LastActivityTime = FrameTime;
            }

            // Only trigger unfocused if we are currently not calling the auto complete
            if (!Activate && !inputActive && AutoCompleteInfo == null)
            {
                if (Plugin.Config.PlaySounds && !PlayedClosingSound)
                {
                    PlayedClosingSound = true;
                    UIGlobals.PlaySoundEffect(ChatCloseSfx);
                }

                if (activeTab.CurrentChannel.UseTempChannel)
                {
                    activeTab.CurrentChannel.ResetTempChannel();
                    SetChannel(Plugin.CurrentTab.CurrentChannel.Channel);
                }
            }

            using (var context = ImRaii.ContextPopupItem("ChatInputContext"))
            {
                if (context)
                {
                    using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, normalColor);
                    if (ImGui.Selectable(Language.ChatLog_HideChat))
                        UserHide();
                }
            }
        }

        ImGui.SameLine();

        if (ImGuiUtil.IconButton(FontAwesomeIcon.Cog, width: (int)buttonWidth))
            Plugin.SettingsWindow.Toggle();

        if (Plugin.Config.ShowHideButton)
        {
            ImGui.SameLine();
            if (ImGuiUtil.IconButton(FontAwesomeIcon.EyeSlash, width: (int)buttonWidth))
                UserHide();
        }

        if (ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows))
            LastActivityTime = FrameTime;

        if (!showNovice)
            return;

        ImGui.SameLine();

        if (ImGuiUtil.IconButton(FontAwesomeIcon.Leaf))
            GameFunctions.GameFunctions.ClickNoviceNetworkButton();
    }

    internal Dictionary<string, InputChannel> GetValidChannels()
    {
        var channels = new Dictionary<string, InputChannel>();
        foreach (var channel in Enum.GetValues<InputChannel>())
        {
            if (!channel.IsValid())
                continue;

            var name = Sheets.LogFilterSheet.FirstOrNull(row => row.LogKind == (byte) channel.ToChatType())?.Name.ToString() ?? channel.ToChatType().Name();
            if (channel.IsLinkshell())
            {
                var lsName = Plugin.Functions.Chat.GetLinkshellName(channel.LinkshellIndex());
                if (string.IsNullOrWhiteSpace(lsName))
                    continue;

                name += $": {lsName}";
            }

            if (channel.IsCrossLinkshell())
            {
                var lsName = Plugin.Functions.Chat.GetCrossLinkshellName(channel.LinkshellIndex());
                if (string.IsNullOrWhiteSpace(lsName))
                    continue;

                name += $": {lsName}";
            }

            // Check if the linkshell with this index is registered in
            // the ExtraChat plugin by seeing if the command is
            // registered. The command gets registered only if a
            // linkshell is assigned (and even gets unassigned if the
            // index changes!).
            if (channel.IsExtraChatLinkshell())
                if (!Plugin.CommandManager.Commands.ContainsKey(channel.Prefix()))
                    continue;

            channels.Add(name, channel);
        }

        return channels;
    }

    private void DrawChannelName(Tab activeTab)
    {
        var currentChannel = ReadChannelName(activeTab);
        if (!currentChannel.SequenceEqual(PreviousChannel))
            PreviousChannel = currentChannel;

        DrawChunks(currentChannel);
    }

    private Chunk[] ReadChannelName(Tab activeTab)
    {
        Chunk[] channelNameChunks;
        // Check the temp channel before others
        if (activeTab.CurrentChannel.UseTempChannel)
        {
            if (activeTab.CurrentChannel.TempTellTarget != null && activeTab.CurrentChannel.TempTellTarget.IsSet())
            {
                channelNameChunks = GenerateTellTargetName(activeTab.CurrentChannel.TempTellTarget);
            }
            else
            {
                string name;
                if (activeTab.CurrentChannel.TempChannel.IsLinkshell())
                {
                    var idx = (uint) activeTab.CurrentChannel.TempChannel - (uint) InputChannel.Linkshell1;
                    var lsName = Plugin.Functions.Chat.GetLinkshellName(idx);
                    name = $"LS #{idx + 1}: {lsName}";
                }
                else if (activeTab.CurrentChannel.TempChannel.IsCrossLinkshell())
                {
                    var idx = (uint) activeTab.CurrentChannel.TempChannel - (uint) InputChannel.CrossLinkshell1;
                    var cwlsName = Plugin.Functions.Chat.GetCrossLinkshellName(idx);
                    name = $"CWLS [{idx + 1}]: {cwlsName}";
                }
                else
                {
                    name = activeTab.CurrentChannel.TempChannel.ToChatType().Name();
                }

                channelNameChunks = [new TextChunk(ChunkSource.None, null, name)];
            }
        }
        else if (activeTab.CurrentChannel.TellTarget?.IsSet() == true)
        {
            channelNameChunks = GenerateTellTargetName(activeTab.CurrentChannel.TellTarget);
        }
        else if (activeTab is { Channel: { } channel })
        {
            if (channel == InputChannel.Tell && activeTab.TellTarget.IsSet())
            {
                channelNameChunks = GenerateTellTargetName(activeTab.TellTarget);
            }
            else
            {
                // We cannot lookup ExtraChat channel names from index over
                // IPC so we just don't show the name if it's the tabs channel.
                //
                // We don't call channel.ToChatType().Name() as it has the
                // long name as used in the settings window.
                channelNameChunks = [new TextChunk(ChunkSource.None, null, channel.IsExtraChatLinkshell() ? $"ECLS [{channel.LinkshellIndex() + 1}]" : channel.ToChatType().Name())];
            }
        }
        else if (Plugin.ExtraChat.ChannelOverride is var (overrideName, _))
        {
            // If the current channel is not an ExtraChat Linkshell add a warning for the user
            var warning = activeTab.CurrentChannel.Channel.IsExtraChatLinkshell()
                ? ""
                : $" (Warning: {activeTab.CurrentChannel.Channel.ToChatType().Name()})";

            channelNameChunks = [new TextChunk(ChunkSource.None, null, $"{overrideName}{warning}")];
        }
        else if (ScreenshotMode && activeTab.CurrentChannel.Channel is InputChannel.Tell && activeTab.CurrentChannel.TellTarget != null)
        {
            if (!string.IsNullOrWhiteSpace(activeTab.CurrentChannel.TellTarget.Name) && activeTab.CurrentChannel.TellTarget.World != 0)
            {
                // Note: don't use HidePlayerInString here because abbreviation settings do not affect this.
                var playerName = HashPlayer(activeTab.CurrentChannel.TellTarget.Name, activeTab.CurrentChannel.TellTarget.World);
                var world = Sheets.WorldSheet.TryGetRow(activeTab.CurrentChannel.TellTarget.World, out var worldRow)
                    ? worldRow.Name.ExtractText()
                    : "???";

                channelNameChunks =
                [
                    new TextChunk(ChunkSource.None, null, "Tell "),
                    new TextChunk(ChunkSource.None, null, playerName),
                    new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
                    new TextChunk(ChunkSource.None, null, world)
                ];
            }
            else
            {
                // We still need to censor the name if we couldn't read valid data.
                channelNameChunks = [new TextChunk(ChunkSource.None, null, "Tell")];
            }
        }
        else
        {
            channelNameChunks = activeTab.CurrentChannel.Name.Count > 0
                ? activeTab.CurrentChannel.Name.ToArray()
                : [new TextChunk(ChunkSource.None, null, activeTab.CurrentChannel.Channel.ToChatType().Name())];
        }

        return channelNameChunks;
    }

    internal void SetChannel(InputChannel? channel)
    {
        channel ??= InputChannel.Say;
        if (channel != InputChannel.Tell)
        {
            Plugin.CurrentTab.CurrentChannel.TellTarget = null;
            Plugin.CurrentTab.CurrentChannel.TempTellTarget = null;
        }

        // Instead of calling SetChannel(), we ask the ExtraChat plugin to set a
        // channel override by just calling the command directly.
        if (channel.Value.IsExtraChatLinkshell())
        {
            // Check that the command is registered in Dalamud so the game code
            // never sees the command itself.
            if (!Plugin.CommandManager.Commands.ContainsKey(channel.Value.Prefix()))
                return;

            // Send the command through the game chat. We can't call
            // ICommandManager.ProcessCommand() here because ExtraChat only
            // registers stub handlers and actually processes its commands in a
            // SendMessage detour.
            var bytes = Encoding.UTF8.GetBytes(channel.Value.Prefix());
            ChatBox.SendMessageUnsafe(bytes);

            Plugin.CurrentTab.CurrentChannel.Channel = channel.Value;
            return;
        }

        var target = Plugin.CurrentTab.CurrentChannel.TempTellTarget ?? Plugin.CurrentTab.CurrentChannel.TellTarget;
        Plugin.Functions.Chat.SetChannel(channel.Value, target);
    }

    private Chunk[] GenerateTellTargetName(TellTarget tellTarget)
    {
        var playerName = tellTarget.Name;
        if (ScreenshotMode)
            // Note: don't use HidePlayerInString here because
            // abbreviation settings do not affect this.
            playerName = HashPlayer(tellTarget.Name, tellTarget.World);

        var world = Sheets.WorldSheet.TryGetRow(tellTarget.World, out var worldRow)
            ? worldRow.Name.ToString()
            : "???";

        return
        [
            new TextChunk(ChunkSource.None, null, "Tell "),
            new TextChunk(ChunkSource.None, null, playerName),
            new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
            new TextChunk(ChunkSource.None, null, world)
        ];
    }

    // v0.6.0 — pop-out windows route submission through this wrapper.
    // The main-window Chat buffer is briefly used as a vehicle for
    // SendChatBox (which reads it directly) and restored afterwards so
    // the main window does not visibly lose any half-typed input.
    internal void SendChatBoxFromExternal(Tab tab, string text)
    {
        var saved = Chat;
        Chat = text;
        SendChatBox(tab);
        Chat = saved;
    }

    internal void SendChatBox(Tab activeTab)
    {
        if (!string.IsNullOrWhiteSpace(Chat))
        {
            var trimmed = Chat.Trim();
            AddBacklog(trimmed);
            InputBacklogIdx = -1;

            if (HasTranslationCommand(trimmed))
            {
                activeTab.CurrentChannel.ResetTempChannel();
                Chat = string.Empty;
                return;
            }

            if (TellSpecial)
            {
                var tellBytes = Encoding.UTF8.GetBytes(trimmed);
                AutoTranslate.ReplaceWithPayload(ref tellBytes);

                Plugin.Functions.Chat.SendTellUsingCommandInner(tellBytes);
                TellSpecial = false;

                activeTab.CurrentChannel.ResetTempChannel();
                Chat = string.Empty;
                return;
            }

            if (!trimmed.StartsWith('/'))
            {
                var target = activeTab.TellTarget.IsSet() ? activeTab.TellTarget : activeTab.CurrentChannel.TempTellTarget ?? activeTab.CurrentChannel.TellTarget;
                if (target != null)
                {
                    // ContentId 0 is a case where we can't directly send messages, so we send a /tell formatted message and let the game handle it
                    if (target.ContentId == 0)
                    {
                        trimmed = $"/tell {target.ToTargetString()} {trimmed}";
                        var tellBytes = Encoding.UTF8.GetBytes(trimmed);
                        AutoTranslate.ReplaceWithPayload(ref tellBytes);

                        ChatBox.SendMessageUnsafe(tellBytes);

                        activeTab.CurrentChannel.ResetTempChannel();
                        Chat = string.Empty;
                        return;
                    }

                    var reason = target.Reason;
                    var world = Sheets.WorldSheet.GetRow(target.World);
                    if (world is { IsPublic: true })
                    {
                        if (reason == TellReason.Reply && GameFunctions.GameFunctions.GetFriends().Any(friend => friend.ContentId == target.ContentId))
                            reason = TellReason.Friend;

                        var tellBytes = Encoding.UTF8.GetBytes(trimmed);
                        AutoTranslate.ReplaceWithPayload(ref tellBytes);

                        Plugin.Functions.Chat.SendTell(reason, target.ContentId, target.Name, (ushort) world.RowId, tellBytes, trimmed);
                    }

                    activeTab.CurrentChannel.ResetTempChannel();
                    Chat = string.Empty;
                    return;
                }

                if (activeTab.CurrentChannel.UseTempChannel)
                    trimmed = $"{activeTab.CurrentChannel.TempChannel.Prefix()} {trimmed}";
                else
                    trimmed = $"{activeTab.CurrentChannel.Channel.Prefix()} {trimmed}";
            }

            var bytes = Encoding.UTF8.GetBytes(trimmed);
            AutoTranslate.ReplaceWithPayload(ref bytes);

            ChatBox.SendMessageUnsafe(bytes);
        }

        activeTab.CurrentChannel.ResetTempChannel();
        Chat = string.Empty;
    }

    private bool HasTranslationCommand(string trimmed)
    {
        var messageBytes = Encoding.UTF8.GetBytes(trimmed);
        if (AutoTranslate.StartsWithCommand(ref messageBytes))
        {
            ChatBox.SendMessageUnsafe(messageBytes);
            return true;
        }

        return false;
    }

    internal void UserHide()
    {
        CurrentHideState = HideState.User;
    }

    internal void DrawMessageLog(Tab tab, PayloadHandler handler, float childHeight, bool switchedTab)
    {
        using var child = ImRaii.Child("##chat2-messages", new Vector2(-1, childHeight));
        if (!child.Success)
            return;

        if (tab.DisplayTimestamp && Plugin.Config.PrettierTimestamps)
            DrawLogTableStyle(tab, handler, switchedTab);
        else
            DrawLogNormalStyle(tab, handler, switchedTab);
    }

    private void DrawLogNormalStyle(Tab tab, PayloadHandler handler, bool switchedTab)
    {
        using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
            DrawMessages(tab, handler, false);

        if (switchedTab || ImGui.GetScrollY() >= ImGui.GetScrollMaxY())
            ImGui.SetScrollHereY(1f);

        handler.Draw();
    }

    private void DrawLogTableStyle(Tab tab, PayloadHandler handler, bool switchedTab)
    {
        var compact = Plugin.Config.MoreCompactPretty;
        var oldItemSpacing = ImGui.GetStyle().ItemSpacing;
        var oldCellPadding = ImGui.GetStyle().CellPadding;

        using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
        using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, oldCellPadding with { Y = 0 }, compact))
        {
            using var table = ImRaii.Table("timestamp-table", 2, ImGuiTableFlags.PreciseWidths);
            if (!table.Success)
                return;

            ImGui.TableSetupColumn("timestamps", ImGuiTableColumnFlags.WidthFixed);
            ImGui.TableSetupColumn("messages", ImGuiTableColumnFlags.WidthStretch);

            DrawMessages(tab, handler, true, compact, oldCellPadding.Y);

            using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, oldItemSpacing))
            using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, oldCellPadding))
            {
                // Custom styles can have cellPadding that go above 4, which GetScrollY isn't respecting
                var cellPaddingOffset = !compact && oldCellPadding.Y > 4f ? oldCellPadding.Y - 4f : 0f;
                if (switchedTab || ImGui.GetScrollY() + cellPaddingOffset >= ImGui.GetScrollMaxY())
                    ImGui.SetScrollHereY(1f);

                handler.Draw();
            }
        }
    }

    private void DrawMessages(Tab tab, PayloadHandler handler, bool isTable, bool moreCompact = false, float oldCellPaddingY = 0)
    {
        try
        {
            // This may produce ApplicationException which is catched below.
            using var messages = tab.Messages.GetReadOnly(3);

            var reset = false;
            if (LastResize is { IsRunning: true, Elapsed.TotalSeconds: > 0.25 })
            {
                LastResize.Stop();
                LastResize.Reset();
                reset = true;
            }

            var lastPosY = ImGui.GetCursorPosY();
            var lastTimestamp = string.Empty;
            int? lastMessageHash = null;
            var sameCount = 0;

            var maxLines = Plugin.Config.MaxLinesToRender;
            var startLine = messages.Count > maxLines ? messages.Count - maxLines : 0;
            for (var i = startLine; i < messages.Count; i++)
            {
                var message = messages[i];
                if (reset)
                {
                    message.Height[tab.Identifier] = null;
                    message.IsVisible[tab.Identifier] = false;
                }

                if (Plugin.Config.CollapseDuplicateMessages)
                {
                    var messageHash = message.Hash;
                    var same = lastMessageHash == messageHash;
                    if (same)
                    {
                        sameCount += 1;
                        message.IsVisible[tab.Identifier] = false;
                        if (i != messages.Count - 1)
                            continue;
                    }

                    if (sameCount > 0)
                    {
                        ImGui.SameLine();
                        DrawChunks(
                            [new TextChunk(ChunkSource.None, null, $" ({sameCount + 1}x)") { FallbackColour = ChatType.System, Italic = true, }],
                            true,
                            handler,
                            ImGui.GetContentRegionAvail().X
                        );
                        sameCount = 0;
                    }

                    lastMessageHash = messageHash;
                    if (same && i == messages.Count - 1)
                        continue;
                }

                // go to next row
                if (isTable)
                    ImGui.TableNextColumn();

                // Set the height of the previous message. `lastPosY` is set to
                // the top of the previous message, and the current cursor is at
                // the top of the current message.
                if (i > 0)
                {
                    var prevMessage = messages[i - 1];
                    prevMessage.Height.TryGetValue(tab.Identifier, out var prevHeight);
                    if (prevHeight == null || (prevMessage.IsVisible.TryGetValue(tab.Identifier, out var prevVisible) && prevVisible))
                    {
                        var newHeight = ImGui.GetCursorPosY() - lastPosY;

                        // Remove the padding from the bottom of the previous row and the top of the current row.
                        if (isTable && !moreCompact)
                            newHeight -= oldCellPaddingY * 2;

                        if (newHeight != 0)
                            prevMessage.Height[tab.Identifier] = newHeight;
                    }
                }
                lastPosY = ImGui.GetCursorPosY();

                // message has rendered once
                // message isn't visible, so render dummy
                message.Height.TryGetValue(tab.Identifier, out var height);
                message.IsVisible.TryGetValue(tab.Identifier, out var visible);
                if (height != null && !visible)
                {
                    var beforeDummy = ImGui.GetCursorPos();

                    // skip to the message column for vis test
                    if (isTable)
                        ImGui.TableNextColumn();

                    ImGui.Dummy(new Vector2(10f, height.Value));

                    var nowVisible = ImGui.IsItemVisible();
                    if (!nowVisible)
                        continue;

                    if (isTable)
                        ImGui.TableSetColumnIndex(0);

                    ImGui.SetCursorPos(beforeDummy);
                    message.IsVisible[tab.Identifier] = nowVisible;
                }

                if (tab.DisplayTimestamp)
                {
                    var localTime = message.Date.ToLocalTime();
                    // Force the format explicitly per setting. Relying on the
                    // current culture meant a German system locale always
                    // produced 24h regardless of the toggle, so the checkbox
                    // looked dead.
                    var timestamp = Plugin.Config.Use24HourClock
                        ? localTime.ToString("HH:mm", CultureInfo.InvariantCulture)
                        : localTime.ToString("h:mm tt", CultureInfo.InvariantCulture);
                    if (isTable)
                    {
                        if (!Plugin.Config.HideSameTimestamps || timestamp != lastTimestamp)
                        {
                            lastTimestamp = timestamp;
                            ImGui.TextUnformatted(timestamp);

                            // We use an IsItemHovered() check here instead of
                            // just calling Tooltip() to avoid computing the
                            // tooltip string for all visible items on every
                            // frame.
                            if (ImGui.IsItemHovered())
                                ImGuiUtil.Tooltip(localTime.ToString("F"));
                        }
                        else
                        {
                            // Avoids rendering issues caused by emojis in
                            // message content.
                            ImGui.TextUnformatted("");
                        }
                    }
                    else
                    {
                        DrawChunk(new TextChunk(ChunkSource.None, null, $"[{timestamp}] ") { Foreground = 0xFFFFFFFF, });
                        ImGui.SameLine();
                    }
                }

                if (isTable)
                    ImGui.TableNextColumn();

                var lineWidth = ImGui.GetContentRegionAvail().X;
                if (message.Sender.Count > 0)
                {
                    DrawChunks(message.Sender, true, handler, lineWidth);
                    ImGui.SameLine();
                }

                // We need to draw something otherwise the item visibility check below won't work.
                if (message.Content.Count == 0)
                    DrawChunks([new TextChunk(ChunkSource.Content, null, " ")], true, handler, lineWidth);
                else
                    DrawChunks(message.Content, true, handler, lineWidth);

                message.IsVisible[tab.Identifier] = ImGui.IsItemVisible();
            }
        }
        catch (ApplicationException)
        {
            // We couldn't get a reader lock on messages within 3ms, so
            // don't draw anything (and don't log a warning either).
        }
        catch (Exception ex)
        {
            Plugin.Log.Warning(ex, "Error drawing chat log");
        }
    }

    private void DrawTabBar()
    {
        using var tabBar = ImRaii.TabBar("##chat2-tabs");
        if (!tabBar.Success)
            return;

        var previousTab = Plugin.CurrentTab;
        for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++)
        {
            var tab = Plugin.Config.Tabs[tabI];
            if (tab.PopOut)
                continue;

            var unread = tabI == Plugin.LastTab || tab.UnreadMode == UnreadMode.None || tab.Unread == 0 ? "" : $" ({tab.Unread})";
            var flags = ImGuiTabItemFlags.None;
            if (Plugin.WantedTab == tabI)
                flags |= ImGuiTabItemFlags.SetSelected;

            using var tabItem = ImRaii.TabItem($"{tab.Name}{unread}###log-tab-{tabI}", flags);
            DrawTabContextMenu(tab, tabI);

            if (!tabItem.Success)
                continue;

            // v1.2.0 — Active-Tab-Underline-Pill (2 px Akzent statt Background-Fill).
            // Bewusst direkt nach TabItem-Setup; GetItemRectMin/Max referenziert noch
            // das Tab. ImGui hat keine native Underline-API, daher direkter DrawList-Pass.
            {
                var theme = Plugin.ThemeRegistry.Active;
                var min = ImGui.GetItemRectMin();
                var max = ImGui.GetItemRectMax();
                const float pillHeight = 2f;
                ImGui.GetWindowDrawList().AddRectFilled(
                    new Vector2(min.X, max.Y - pillHeight),
                    new Vector2(max.X, max.Y),
                    ColourUtil.RgbaToAbgr(theme.Colors.Accent));
            }

            var hasTabSwitched = Plugin.LastTab != tabI;
            Plugin.LastTab = tabI;

            if (hasTabSwitched)
                TabSwitched(tab, previousTab);

            tab.Unread = 0;
            DrawChatHeaderToolbar(tab);
            DrawMessageLog(tab, PayloadHandler, GetRemainingHeightForMessageLog(), hasTabSwitched);
        }

        Plugin.WantedTab = null;
    }

    private void DrawTabSidebar()
    {
        var currentTab = -1;
        // v1.2.0 — Sidebar fix 44 px, kein Resize. Mehr Platz fürs Chat-Log.
        using var tabTable = ImRaii.Table("tabs-table", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingFixedFit);
        if (!tabTable.Success)
            return;

        ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, 44f);
        ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.WidthStretch, 1);

        ImGui.TableNextColumn();

        var hasTabSwitched = false;
        var childHeight = GetRemainingHeightForMessageLog();
        using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight)))
        {
            if (child)
            {
                var previousTab = Plugin.CurrentTab;
                // Hellion Chat — auto-tell-tabs section divider rendered
                // exactly once before the first temp tab, with a live unit
                // counter pulled directly from the tab list.
                var tempTabHeaderRendered = false;
                var tempTabCount = Plugin.Config.Tabs.Count(t => t.IsTempTab);

                for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++)
                {
                    var tab = Plugin.Config.Tabs[tabI];
                    if (tab.PopOut)
                        continue;

                    if (tab.IsTempTab && !tempTabHeaderRendered)
                    {
                        ImGui.Separator();
                        if (!Plugin.Config.AutoTellTabsCompactDisplay)
                        {
                            ImGui.TextDisabled($"{HellionStrings.AutoTellTabs_SectionHeader} ({tempTabCount})");
                        }
                        tempTabHeaderRendered = true;
                    }

                    var unread = tabI == Plugin.LastTab || tab.UnreadMode == UnreadMode.None || tab.Unread == 0 ? "" : $" ({tab.Unread})";
                    var isCurrentTab = Plugin.LastTab == tabI || Plugin.WantedTab == tabI;

                    var showGreetedAffordance = tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle;

                    if (showGreetedAffordance)
                    {
                        // Greeted toggle sits left of the selectable so the
                        // click areas stay separate. The icon also doubles
                        // as the visual "I'm done with this person" cue.
                        // Compact frame padding keeps the icon dezent next
                        // to the tab name instead of a chunky button block.
                        var greetedIcon = tab.IsGreeted ? FontAwesomeIcon.CheckCircle : FontAwesomeIcon.Check;
                        var greetedTooltip = tab.IsGreeted
                            ? HellionStrings.AutoTellTabs_GreetedTooltip
                            : HellionStrings.AutoTellTabs_UnGreetedTooltip;

                        using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(2, 1)))
                        using (ImRaii.PushColor(ImGuiCol.Button, 0))
                        {
                            if (ImGuiUtil.IconButton(greetedIcon, $"greeted-{tabI}", greetedTooltip))
                            {
                                if (tab.IsGreeted)
                                {
                                    Plugin.AutoTellTabsService.UnmarkGreeted(tab);
                                }
                                else
                                {
                                    Plugin.AutoTellTabsService.MarkGreeted(tab);
                                }
                            }
                        }
                        ImGui.SameLine();
                    }

                    // v1.2.0 — Icon-only Sidebar mit Tooltip beim Hover.
                    // Active-Tab kriegt Akzent-Color am Icon, Greeted-Tabs
                    // werden auf TextDim gedimmt (löst den alten Header-
                    // Dim-Trick ab, da wir keine Selectable mehr nutzen).
                    var theme = Plugin.ThemeRegistry.Active;
                    var icon = TabIconMapping.Resolve(tab);
                    var iconColor = isCurrentTab
                        ? theme.Colors.Accent
                        : (showGreetedAffordance && tab.IsGreeted ? theme.Colors.TextDim : theme.Colors.TextPrimary);

                    bool clicked;
                    using (ImRaii.PushColor(ImGuiCol.Button, 0u))
                    using (ImRaii.PushColor(ImGuiCol.ButtonHovered, ColourUtil.RgbaToAbgr(theme.Colors.SurfaceHover)))
                    using (ImRaii.PushColor(ImGuiCol.ButtonActive, ColourUtil.RgbaToAbgr(theme.Colors.Surface)))
                    using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(iconColor)))
                    using (Plugin.FontManager.FontAwesome.Push())
                    {
                        clicked = ImGui.Button($"{icon.ToIconString()}##sidebar-tab-{tabI}", new Vector2(36f, 32f));
                    }

                    if (isCurrentTab)
                    {
                        // v1.2.0 — Vertikale Akzent-Pill an der linken Window-Kante.
                        // 3 px breit, halbe Tab-Höhe, vertikal zentriert. ImGui hat keine
                        // native Pill-API, daher direkter DrawList-Pass.
                        var min = ImGui.GetItemRectMin();
                        var max = ImGui.GetItemRectMax();
                        const float pillWidth = 3f;
                        var pillHeight = (max.Y - min.Y) * 0.5f;
                        var pillCenterY = (min.Y + max.Y) * 0.5f;
                        ImGui.GetWindowDrawList().AddRectFilled(
                            new Vector2(min.X, pillCenterY - pillHeight * 0.5f),
                            new Vector2(min.X + pillWidth, pillCenterY + pillHeight * 0.5f),
                            ColourUtil.RgbaToAbgr(theme.Colors.Accent),
                            1.5f); // leichter Rounding
                    }

                    // Tooltip mit Tab-Name + Unread-Counter beim Hover.
                    if (ImGui.IsItemHovered())
                    {
                        using var tt = ImRaii.Tooltip();
                        ImGui.TextUnformatted($"{tab.Name}{unread}");
                    }

                    DrawTabContextMenu(tab, tabI);

                    if (clicked)
                        Plugin.WantedTab = tabI;

                    if (!clicked && Plugin.WantedTab != tabI)
                        continue;

                    currentTab = tabI;
                    hasTabSwitched = Plugin.LastTab != tabI;
                    Plugin.LastTab = tabI;
                    if (hasTabSwitched)
                        TabSwitched(tab, previousTab);
                }
            }
        }

        ImGui.TableNextColumn();

        if (currentTab == -1 && Plugin.LastTab < Plugin.Config.Tabs.Count)
        {
            currentTab = Plugin.LastTab;
            Plugin.Config.Tabs[currentTab].Unread = 0;
        }

        if (currentTab > -1)
        {
            DrawChatHeaderToolbar(Plugin.Config.Tabs[currentTab]);
            DrawMessageLog(Plugin.Config.Tabs[currentTab], PayloadHandler, childHeight, hasTabSwitched);
        }

        Plugin.WantedTab = null;
    }

    // Hellion Chat v0.6.1 — visible pop-out trigger right above the message
    // log so users discover the feature without having to right-click the tab.
    // Renders only for the active tab in the main ChatLogWindow; pop-out
    // windows have their own render path and skip this toolbar.
    private void DrawChatHeaderToolbar(Tab tab)
    {
        var avail = ImGui.GetContentRegionAvail().X;
        var iconWidth = ImGui.GetFrameHeight();
        ImGui.SetCursorPosX(ImGui.GetCursorPosX() + avail - iconWidth);

        if (ImGuiUtil.IconButton(FontAwesomeIcon.WindowRestore, tooltip: Language.ChatLog_Tabs_PopOut))
        {
            tab.PopOut = true;
            Plugin.SaveConfig();
        }
    }

    // Hellion Chat v0.6.1 — One-Time-Hint-Banner introducing the chat header
    // pop-out toolbar button and the right-click pathway. Reuses the visual
    // pattern from Popout.cs DrawHintBannerIfNeeded so users see a familiar
    // dismiss-affordance. Returns the vertical space the banner consumed
    // (0 when not shown) so the message log can shrink accordingly.
    private float DrawV061HintBannerIfNeeded()
    {
        if (Plugin.Config.SeenPopOutHeaderHint)
            return 0f;

        var hintText = Resources.HellionStrings.Hint_v061_PopOutHeader_Body;
        var ackLabel = Resources.HellionStrings.Hint_v061_PopOutHeader_Ack;
        var openLabel = Resources.HellionStrings.Hint_v061_PopOutHeader_OpenSettings;

        var startY = ImGui.GetCursorPosY();

        var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f);
        var dismiss = false;
        var openSettings = false;
        // RAII for the style stack so an early return in this block
        // (or a later refactor that introduces one) can never leave the
        // ImGui style stack unbalanced. Matches the convention used
        // elsewhere in this file.
        using (ImRaii.PushColor(ImGuiCol.ChildBg, bg))
        using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1f))
        using (var child = ImRaii.Child("##v061-pop-out-header-hint", new System.Numerics.Vector2(0f, 84f), true))
        {
            if (child)
            {
                ImGui.TextWrapped(hintText);
                if (ImGui.Button(ackLabel))
                    dismiss = true;
                ImGui.SameLine();
                if (ImGui.Button(openLabel))
                {
                    dismiss = true;
                    openSettings = true;
                }
            }
        }

        ImGui.Spacing();

        if (dismiss)
        {
            Plugin.Config.SeenPopOutHeaderHint = true;
            Plugin.SaveConfig();
            Plugin.Log.Debug("v0.6.1 pop-out header hint dismissed");
            if (openSettings)
                Plugin.SettingsWindow.Toggle();
        }

        return ImGui.GetCursorPosY() - startY;
    }

    private void DrawTabContextMenu(Tab tab, int i)
    {
        using var contextMenu = ImRaii.ContextPopupItem($"tab-context-menu-{i}");
        if (!contextMenu.Success)
            return;

        var anyChanged = false;
        var tabs = Plugin.Config.Tabs;

        ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale);
        if (ImGui.InputText("##tab-name", ref tab.Name, 128))
            anyChanged = true;

        if (ImGuiUtil.IconButton(FontAwesomeIcon.TrashAlt, tooltip: Language.ChatLog_Tabs_Delete))
        {
            tabs.RemoveAt(i);
            Plugin.WantedTab = 0;

            anyChanged = true;
        }

        ImGui.SameLine();

        var (leftIcon, leftTooltip) = Plugin.Config.SidebarTabView
            ? (FontAwesomeIcon.ArrowUp, Language.ChatLog_Tabs_MoveUp)
            : (FontAwesomeIcon.ArrowLeft, Language.ChatLog_Tabs_MoveLeft);
        if (ImGuiUtil.IconButton(leftIcon, tooltip: leftTooltip) && i > 0)
        {
            (tabs[i - 1], tabs[i]) = (tabs[i], tabs[i - 1]);
            ImGui.CloseCurrentPopup();
            anyChanged = true;
        }

        ImGui.SameLine();

        var (rightIcon, rightTooltip) = Plugin.Config.SidebarTabView
            ? (FontAwesomeIcon.ArrowDown, Language.ChatLog_Tabs_MoveDown)
            : (FontAwesomeIcon.ArrowRight, Language.ChatLog_Tabs_MoveRight);
        if (ImGuiUtil.IconButton(rightIcon, tooltip: rightTooltip) && i < tabs.Count - 1)
        {
            (tabs[i + 1], tabs[i]) = (tabs[i], tabs[i + 1]);
            ImGui.CloseCurrentPopup();
            anyChanged = true;
        }

        ImGui.SameLine();
        if (ImGuiUtil.IconButton(FontAwesomeIcon.WindowRestore, tooltip: Language.ChatLog_Tabs_PopOut))
        {
            tab.PopOut = true;
            anyChanged = true;
        }

        if (anyChanged)
            Plugin.SaveConfig();
    }

    internal readonly List<bool> PopOutDocked = [];
    internal readonly HashSet<Guid> PopOutWindows = [];

    // v0.6.0 — live enumeration of all active Popout windows so the
    // KeybindManager can find a focused ChatInputBar to forward tab-cycle
    // keybinds to. Filter on IsOpen prevents touching closed-but-still-
    // registered popouts.
    internal IEnumerable<Popout> ActivePopouts =>
        Plugin.WindowSystem.Windows.OfType<Popout>().Where(p => p.IsOpen);
    private void AddPopOutsToDraw()
    {
        HandlerLender.ResetCounter();

        if (PopOutDocked.Count != Plugin.Config.Tabs.Count)
        {
            PopOutDocked.Clear();
            PopOutDocked.AddRange(Enumerable.Repeat(false, Plugin.Config.Tabs.Count));
        }

        for (var i = 0; i < Plugin.Config.Tabs.Count; i++)
        {
            var tab = Plugin.Config.Tabs[i];
            if (!tab.PopOut)
                continue;

            if (PopOutWindows.Contains(tab.Identifier))
                continue;

            var window = new Popout(this, tab, i);

            Plugin.WindowSystem.AddWindow(window);
            PopOutWindows.Add(tab.Identifier);
        }
    }

    private unsafe void DrawAutoComplete()
    {
        if (AutoCompleteInfo == null)
            return;

        AutoCompleteList ??= AutoTranslate.Matching(AutoCompleteInfo.ToComplete, Plugin.Config.SortAutoTranslate);
        if (AutoCompleteOpen)
        {
            ImGui.OpenPopup(AutoCompleteId);
            AutoCompleteOpen = false;
        }

        ImGui.SetNextWindowSize(new Vector2(400, 300) * ImGuiHelpers.GlobalScale);
        using var popup = ImRaii.Popup(AutoCompleteId);
        if (!popup.Success)
        {
            if (ActivatePos == -1)
                ActivatePos = AutoCompleteInfo.EndPos;

            AutoCompleteInfo = null;
            AutoCompleteList = null;
            Activate = true;
            return;
        }

        ImGui.SetNextItemWidth(-1);
        if (ImGui.InputTextWithHint("##auto-complete-filter", Language.AutoTranslate_Search_Hint, ref AutoCompleteInfo.ToComplete, 256, ImGuiInputTextFlags.CallbackAlways | ImGuiInputTextFlags.CallbackHistory, AutoCompleteCallback))
        {
            AutoCompleteList = AutoTranslate.Matching(AutoCompleteInfo.ToComplete, Plugin.Config.SortAutoTranslate);
            AutoCompleteSelection = 0;
            AutoCompleteShouldScroll = true;
        }

        var selected = -1;
        if (ImGui.IsItemActive() && ImGui.GetIO().KeyCtrl)
        {
            for (var i = 0; i < 10 && i < AutoCompleteList.Count; i++)
            {
                var num = (i + 1) % 10;
                var key = ImGuiKey.Key0 + num;
                var key2 = ImGuiKey.Keypad0 + num;
                if (ImGui.IsKeyDown(key) || ImGui.IsKeyDown(key2))
                    selected = i;
            }
        }

        if (ImGui.IsItemDeactivated())
        {
            if (ImGui.IsKeyDown(ImGuiKey.Escape))
            {
                ImGui.CloseCurrentPopup();
                return;
            }

            var enter = ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter);
            if (AutoCompleteList.Count > 0 && enter)
                selected = AutoCompleteSelection;
        }

        if (ImGui.IsWindowAppearing())
        {
            FixCursor = true;
            ImGui.SetKeyboardFocusHere(-1);
        }

        using var child = ImRaii.Child("##auto-complete-list", Vector2.Zero, false, ImGuiWindowFlags.HorizontalScrollbar);
        if (!child.Success)
            return;

        var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
        try
        {
            clipper.Begin(AutoCompleteList.Count);
            while (clipper.Step())
            {
                for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
                {
                    var entry = AutoCompleteList[i];

                    var highlight = AutoCompleteSelection == i;
                    var clicked = ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight) || selected == i;
                    if (i < 10)
                    {
                        var button = (i + 1) % 10;
                        var text = string.Format(Language.AutoTranslate_Completion_Key, button);
                        var size = ImGui.CalcTextSize(text);

                        ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X);

                        using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]))
                            ImGui.TextUnformatted(text);
                    }

                    if (!clicked)
                        continue;

                    var before = Chat[..AutoCompleteInfo.StartPos];
                    var after = Chat[AutoCompleteInfo.EndPos..];
                    var replacement = $"<at:{entry.Group},{entry.Row}>";
                    Chat = $"{before}{replacement}{after}";
                    ImGui.CloseCurrentPopup();
                    Activate = true;
                    ActivatePos = AutoCompleteInfo.StartPos + replacement.Length;
                }
            }

            if (!AutoCompleteShouldScroll)
                return;

            AutoCompleteShouldScroll = false;
            var selectedPos = clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f);
            ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y);
        }
        finally
        {
            // ImGuiListClipperPtr wraps an unmanaged ImGuiListClipper allocated above.
            // Without Destroy() the unmanaged block leaks per autocomplete render.
            clipper.Destroy();
        }
    }

    private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data)
    {
        if (FixCursor && AutoCompleteInfo != null)
        {
            FixCursor = false;
            data.CursorPos = AutoCompleteInfo.ToComplete.Length;
            data.SelectionStart = data.SelectionEnd = data.CursorPos;
        }

        if (AutoCompleteList == null)
            return 0;

        switch (data.EventKey)
        {
            case ImGuiKey.UpArrow:
                if (AutoCompleteSelection == 0)
                    AutoCompleteSelection = AutoCompleteList.Count - 1;
                else
                    AutoCompleteSelection--;

                AutoCompleteShouldScroll = true;
                return 1;
            case ImGuiKey.DownArrow:
                if (AutoCompleteSelection == AutoCompleteList.Count - 1)
                    AutoCompleteSelection = 0;
                else
                    AutoCompleteSelection++;

                AutoCompleteShouldScroll = true;
                return 1;
            default:
                if(ImGui.IsKeyPressed(ImGuiKey.Tab))
                {
                    if (AutoCompleteSelection == AutoCompleteList.Count - 1)
                        AutoCompleteSelection = 0;
                    else
                        AutoCompleteSelection++;

                    AutoCompleteShouldScroll = true;
                    return 1;
                }
                break;
        }

        return 0;
    }

    private unsafe int Callback(scoped ref ImGuiInputTextCallbackData data)
    {
        // We play the opening sound here only if closing sound has been played before
        if (Plugin.Config.PlaySounds && PlayedClosingSound)
        {
            PlayedClosingSound = false;
            UIGlobals.PlaySoundEffect(ChatOpenSfx);
        }

        // Set the cursor pos to the user selected
        if (Plugin.InputPreview.SelectedCursorPos != -1)
            data.CursorPos = Plugin.InputPreview.SelectedCursorPos;
        Plugin.InputPreview.SelectedCursorPos = -1;

        CursorPos = data.CursorPos;
        if (data.EventFlag == ImGuiInputTextFlags.CallbackCompletion)
        {
            if (data.CursorPos == 0)
            {
                AutoCompleteInfo = new AutoCompleteInfo(
                    string.Empty,
                    data.CursorPos,
                    data.CursorPos
                );
                AutoCompleteOpen = true;
                AutoCompleteSelection = 0;

                return 0;
            }

            int white;
            for (white = data.CursorPos - 1; white >= 0; white--)
                if (data.Buf[white] == ' ')
                    break;

            var start = data.Buf + white + 1;
            var end = data.CursorPos - white - 1;
            var utf8Message = Marshal.PtrToStringUTF8((nint)start, end);
            var correctedCursor = data.CursorPos - (end - utf8Message.Length);
            AutoCompleteInfo = new AutoCompleteInfo(
                utf8Message,
                white + 1,
                correctedCursor
            );
            AutoCompleteOpen = true;
            AutoCompleteSelection = 0;
            return 0;
        }

        if (data.EventFlag == ImGuiInputTextFlags.CallbackCharFilter)
            if (!Plugin.Functions.Chat.IsCharValid((char) data.EventChar))
                return 1;

        if (Activate)
        {
            Activate = false;
            data.CursorPos = ActivatePos > -1 ? ActivatePos : Chat.Length;
            data.SelectionStart = data.SelectionEnd = data.CursorPos;
            ActivatePos = -1;
        }

        Plugin.CommandHelpWindow.IsOpen = false;
        var text = MemoryHelper.ReadString((nint) data.Buf, data.BufTextLen);
        if (text.StartsWith('/'))
        {
            var command = text.Split(' ')[0];
            if (AllCommands.TryGetValue(command, out var textCommand))
                Plugin.CommandHelpWindow.UpdateContent(textCommand.Description);
            else if (Plugin.CommandManager.Commands.TryGetValue(command, out var info) && info.ShowInHelp)
                Plugin.CommandHelpWindow.UpdateContent(info.HelpMessage);
        }

        if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory)
            return 0;

        var prevPos = InputBacklogIdx;
        switch (data.EventKey)
        {
            case ImGuiKey.UpArrow:
                switch (InputBacklogIdx)
                {
                    case -1:
                        var offset = 0;

                        if (!string.IsNullOrWhiteSpace(Chat))
                        {
                            AddBacklog(Chat);
                            offset = 1;
                        }

                        InputBacklogIdx = InputHistoryService.Count - 1 - offset;
                        break;
                    case > 0:
                        InputBacklogIdx--;
                        break;
                }
                break;
            case ImGuiKey.DownArrow:
                if (InputBacklogIdx != -1)
                    if (++InputBacklogIdx >= InputHistoryService.Count)
                        InputBacklogIdx = -1;
                break;
        }

        if (prevPos == InputBacklogIdx)
            return 0;

        var historyStr = InputHistoryService.GetByCursor(InputBacklogIdx) ?? string.Empty;
        data.DeleteChars(0, data.BufTextLen);
        data.InsertChars(0, historyStr);

        return 0;
    }

    internal void DrawChunks(IReadOnlyList<Chunk> chunks, bool wrap = true, PayloadHandler? handler = null, float lineWidth = 0f)
    {
        using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero);

        for (var i = 0; i < chunks.Count; i++)
        {
            if (chunks[i] is TextChunk text && string.IsNullOrEmpty(text.Content))
                continue;

            DrawChunk(chunks[i], wrap, handler, lineWidth);

            if (i < chunks.Count - 1)
            {
                ImGui.SameLine();
            }
            else if (chunks[i].Link is EmotePayload && Plugin.Config.ShowEmotes)
            {
                // Emote payloads seem to not automatically put newlines, which
                // is an issue when modern mode is disabled.
                ImGui.SameLine();
                // Use default ImGui behavior for newlines.
                ImGui.TextUnformatted("");
            }
        }
    }

    private void DrawChunk(Chunk chunk, bool wrap = true, PayloadHandler? handler = null, float lineWidth = 0f)
    {
        if (chunk is IconChunk icon)
        {
            DrawIcon(chunk, icon, handler);
            return;
        }

        if (chunk is not TextChunk text)
            return;

        if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
        {
            var emoteSize = ImGui.CalcTextSize("W");
            emoteSize = emoteSize with { Y = emoteSize.X } * 1.5f;

            // TextWrap doesn't work for emotes, so we have to wrap them manually
            if (ImGui.GetContentRegionAvail().X < emoteSize.X)
                ImGui.NewLine();

            // We only draw a dummy if it is still loading, in the case it failed we draw the actual name
            var image = EmoteCache.GetEmote(emotePayload.Code);
            if (image is { Failed: false })
            {
                if (image.IsLoaded)
                    image.Draw(emoteSize);
                else
                    ImGui.Dummy(emoteSize);

                if (ImGui.IsItemHovered())
                    ImGuiUtil.Tooltip(emotePayload.Code);

                return;
            }
        }

        var colour = text.Foreground;
        if (colour == null && text.FallbackColour != null)
        {
            var type = text.FallbackColour.Value;
            colour = Plugin.Config.ChatColours.TryGetValue(type, out var col) ? col : type.DefaultColor();
        }

        var push = colour != null;
        var uColor = push ? ColourUtil.RgbaToAbgr(colour!.Value) : 0;
        using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, uColor, push);

        var useCustomItalicFont = Plugin.Config.FontsEnabled && Plugin.FontManager.ItalicFont != null;
        if (text.Italic)
            (useCustomItalicFont ? Plugin.FontManager.ItalicFont! : Plugin.FontManager.AxisItalic).Push();

        // Check for contains here as sometimes there are multiple
        // TextChunks with the same PlayerPayload but only one has the name.
        // E.g. party chat with cross world players adds extra chunks.
        //
        // Note: This has been null before, I'm guessing due to some issues with
        // other plugins. New TextChunks will now enforce empty string in ctor,
        // but old ones may still be null.
        // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
        var content = text.Content ?? "";
        if (ScreenshotMode)
        {
            if (chunk.Link is PlayerPayload playerPayload)
                content = HidePlayerInString(content, playerPayload.PlayerName, playerPayload.World.RowId);
            else if (Plugin.PlayerState.IsLoaded)
                content = HidePlayerInString(content, Plugin.PlayerState.CharacterName, Plugin.PlayerState.HomeWorld.RowId);
        }

        if (wrap)
        {
            ImGuiUtil.WrapText(content, chunk, handler, DefaultText, lineWidth);
        }
        else
        {
            ImGui.TextUnformatted(content);
            ImGuiUtil.PostPayload(chunk, handler);
        }

        if (text.Italic)
            (useCustomItalicFont ? Plugin.FontManager.ItalicFont! : Plugin.FontManager.AxisItalic).Pop();
    }

    internal void DrawIcon(Chunk chunk, IconChunk icon, PayloadHandler? handler)
    {
        if (!IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out var entry))
            return;

        var iconTexture = Plugin.TextureProvider.GetFromGame("common/font/fonticon_ps5.tex").GetWrapOrDefault();
        if (iconTexture == null)
            return;

        var texSize = new Vector2(iconTexture.Width, iconTexture.Height);

        var sizeRatio = FontManager.GetFontSize() / entry.Height;
        var size = new Vector2(entry.Width, entry.Height) * sizeRatio * ImGuiHelpers.GlobalScale;

        var uv0 = new Vector2(entry.Left, entry.Top + 170) * 2 / texSize;
        var uv1 = new Vector2(entry.Left + entry.Width, entry.Top + entry.Height + 170) * 2 / texSize;

        ImGui.Image(iconTexture.Handle, size, uv0, uv1);
        ImGuiUtil.PostPayload(chunk, handler);

    }

    internal string HidePlayerInString(string str, string playerName, uint worldId)
    {
        var expected = Plugin.Functions.Chat.AbbreviatePlayerName(playerName);
        var hash = HashPlayer(playerName, worldId);
        return str.Replace(playerName, expected).Replace(expected, hash);
    }

    private string HashPlayer(string playerName, uint worldId)
    {
        var hashCode = $"{Salt}{playerName}{worldId}".GetHashCode();
        return $"Player {hashCode:X8}";
    }

    // Snap threshold in pixels: at least this much of the window must overlap
    // a visible viewport so the user can still grab the first tab header.
    // Below the threshold the window is considered off-screen.
    private const int OnScreenMinOverlapX = 100;
    private const int OnScreenMinOverlapY = 40;

    // Default snap position relative to the primary viewport (top-left with a
    // safety margin from the game title bar).
    private static readonly Vector2 SafeDefaultOffset = new(50, 50);

    private void EnsureWindowOnScreen(string source)
    {
        if (LastWindowSize.X < 1 || LastWindowSize.Y < 1)
            return;

        var viewport = ImGui.GetMainViewport();
        var visibleMin = viewport.WorkPos;
        var visibleMax = viewport.WorkPos + viewport.WorkSize;

        var overlapMin = Vector2.Max(LastWindowPos, visibleMin);
        var overlapMax = Vector2.Min(LastWindowPos + LastWindowSize, visibleMax);
        var overlap = overlapMax - overlapMin;

        if (overlap.X >= OnScreenMinOverlapX && overlap.Y >= OnScreenMinOverlapY)
            return;

        ApplySafeDefaultPosition(source);
    }

    private void ApplySafeDefaultPosition(string source)
    {
        var viewport = ImGui.GetMainViewport();
        var safePos = viewport.WorkPos + SafeDefaultOffset;
        Position = safePos;
        Plugin.Log.Info(
            $"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}.");

        // Pop-outs are intentionally non-persistent (cleared on plugin reload),
        // so an off-screen pop-out can never survive a session boundary. The
        // main window above is the only persistence target that needs an
        // explicit recovery path.
    }
}
