using System.Diagnostics;
using System.Globalization;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
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 FFXIVClientStructs.FFXIV.Client.UI.Agent;
using HellionChat._Helpers;
using HellionChat.Code;
using HellionChat.GameFunctions;
using HellionChat.GameFunctions.Types;
using HellionChat.Integrations;
using HellionChat.Resources;
using HellionChat.Util;
using Lumina.Excel.Sheets;
using Lumina.Extensions;
using Microsoft.Extensions.Logging;

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;
    private readonly SymbolPicker _symbolPicker;

    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;

    // UI-11: the main-window input buffer for which a plugin-disclosure
    // warning was already shown. Mirrors _disclosureArmedBuffer in
    // ChatInputBar — a second Enter on the same buffer sends it anyway.
    private string? _disclosureArmedBufferMain;

    // Input history extracted into InputHistoryService so pop-out windows share
    // the same Up/Down history. Cursor stays window-local (independent navigation).
    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;

    // Guards against off-screen positions after a display layout change.
    // One-shot bounds check on first draw; manual reset button bypasses it.
    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;

    // One-shot guard so a recurring draw failure doesn't spam the
    // notification stack frame-by-frame. Resets only on next plugin reload.
    private bool NotifiedDrawFailure;

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

    private readonly ILogger<ChatLogWindow> _logger;
    private readonly ILoggerFactory _loggerFactory;

    internal ChatLogWindow(
        Plugin plugin,
        ILogger<ChatLogWindow> logger,
        ILoggerFactory loggerFactory
    )
        : base($"{Plugin.PluginName}###chat2")
    {
        Plugin = plugin;
        _logger = logger;
        _loggerFactory = loggerFactory;
        Salt = new Random().Next().ToString();

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

        PositionCondition = ImGuiCond.Always;

        IsOpen = true;
        RespectCloseHotkey = false;
        DisableWindowSounds = true;
        // AllowBackgroundBlur is set centrally in Plugin.Setup after AddWindow.

        PayloadHandler = new PayloadHandler(this, _loggerFactory.CreateLogger<PayloadHandler>());
        HandlerLender = new Lender<PayloadHandler>(() =>
            new PayloadHandler(this, _loggerFactory.CreateLogger<PayloadHandler>())
        );

        SetUpTextCommandChannels();
        SetUpAllCommands();

        // Cache wrapper instances so Dispose can detach the same event objects
        // without going through Register() again.
        _clearHellionCommand = Plugin.Commands.Register(
            "/clearhellion",
            "Clear the Hellion Chat log"
        );
        _hellionCommand = Plugin.Commands.Register("/hellion");
        _clearHellionCommand.Execute += ClearLog;
        _hellionCommand.Execute += ToggleChat;

        _symbolPicker = new SymbolPicker();

        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;
        }

        // ---------------------------------------------------------------
        // Cherry-picked from ChatTwo upstream ee7768ac (Infiziert90, 2026-05-16)
        // - Replace the chat input when args.AddIfNotPresent / args.Input starts
        //   with a slash. Vanilla actions like the Friend List "/tell" entry and
        //   other plugins push slash commands through these args; appending them
        //   to existing text would produce inputs like "test/tell user@world".
        // ---------------------------------------------------------------
        if (args.AddIfNotPresent != null && !Chat.Contains(args.AddIfNotPresent))
        {
            if (args.AddIfNotPresent.StartsWith('/'))
                Chat = args.AddIfNotPresent;
            else
                Chat += args.AddIfNotPresent;
        }

        if (args.Input != null)
        {
            if (args.Input.StartsWith('/'))
                Chat = args.Input;
            else
                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.IsChannelOrExistingLinkshell(targetChannel.Value)
            )
            {
                _logger.LogWarning(
                    $"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;
                _logger.LogTrace("HideState: → User (chat hide command)");
                break;
            case "show":
                CurrentHideState = HideState.None;
                _logger.LogTrace("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,
                };
                _logger.LogTrace($"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);
        }
    }

    // Delegates to InputHistoryService so pop-out ChatInputBar instances share
    // history. Deduplication lives inside the service.
    private void AddBacklog(string message)
    {
        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;

        // Header toolbar height is not subtracted by GetContentRegionAvail automatically
        // (it renders outside the normal layout path), so we subtract it explicitly.
        // The hint banner renders before this block so ImGui already accounts for it.
        height -= ImGui.GetFrameHeightWithSpacing();

        // StatusBar.Height now bakes in its own DPI-aware 2px spacer, so the
        // window reservation is just Height -- no extra +2 (v1.4.8 B1).
        height -= StatusBar.Height;

        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);
    }

    // PM-2b v1.5.4 header quick-picker. Two scrollable sections -- every
    // built-in plus custom theme, and every tab. Clicking a theme arms
    // the PM-1 crossfade via ThemeRegistry.Switch; clicking a tab routes
    // through ChangeTab so LastActivityTime stays consistent with the
    // sidebar and top-bar click paths. DontClosePopups keeps the popup
    // open so the user can hop between entries without re-opening it.
    private void DrawQuickPickerPopup()
    {
        using var popup = ImRaii.Popup("##hellion-quick-picker");
        if (!popup.Success)
            return;

        ImGui.TextUnformatted(HellionStrings.Settings_QuickPicker_Themes_Header);
        ImGui.Separator();

        var activeSlug = Plugin.ThemeRegistry.Active.Slug;
        var allThemes = Plugin
            .ThemeRegistry.AllBuiltIns()
            .Concat(Plugin.ThemeRegistry.AllCustom())
            .ToList();

        using (
            var scroll = ImRaii.Child(
                "##hellion-quick-picker-themes",
                new Vector2(220f, Math.Min(allThemes.Count * 22f, 200f))
            )
        )
        {
            if (scroll.Success)
            {
                foreach (var theme in allThemes)
                {
                    var isActive = string.Equals(
                        theme.Slug,
                        activeSlug,
                        StringComparison.OrdinalIgnoreCase
                    );
                    DrawQuickPickerGlyph(isActive);
                    if (
                        ImGui.Selectable(
                            $"{theme.Name}##quick-theme-{theme.Slug}",
                            isActive,
                            ImGuiSelectableFlags.DontClosePopups
                        ) && !isActive
                    )
                        Plugin.ThemeRegistry.Switch(theme.Slug);
                }
            }
        }

        ImGui.Spacing();
        ImGui.TextUnformatted(HellionStrings.Settings_QuickPicker_Tabs_Header);
        ImGui.Separator();

        var tabs = Plugin.Config.Tabs;
        var activeTabIndex = Plugin.LastTab;
        using (
            var scroll = ImRaii.Child(
                "##hellion-quick-picker-tabs",
                new Vector2(220f, Math.Min(tabs.Count * 22f, 200f))
            )
        )
        {
            if (scroll.Success)
            {
                for (var i = 0; i < tabs.Count; i++)
                {
                    var isActive = i == activeTabIndex;
                    DrawQuickPickerGlyph(isActive);
                    if (
                        ImGui.Selectable(
                            $"{tabs[i].Name}##quick-tab-{i}",
                            isActive,
                            ImGuiSelectableFlags.DontClosePopups
                        ) && !isActive
                    )
                        ChangeTab(i);
                }
            }
        }
    }

    // Leading check-glyph slot for a quick-picker row. Active rows get a
    // FontAwesome check; inactive rows get a same-width blank so the
    // labels stay aligned. The glyph font push stays on its own line so
    // it never bleeds into the body-font Selectable label.
    private void DrawQuickPickerGlyph(bool isActive)
    {
        using (Plugin.FontManager.FontAwesome.Push())
        {
            var check = FontAwesomeIcon.Check.ToIconString();
            if (isActive)
                ImGui.TextUnformatted(check);
            else
                ImGui.Dummy(new Vector2(ImGui.CalcTextSize(check).X, ImGui.GetTextLineHeight()));
        }
        ImGui.SameLine();
    }

    private void TabSwitched(Tab newTab, Tab previousTab)
    {
        // Use the fixed channel if set by the user. Otherwise, if the new tab
        // has no channel state yet (fresh from JSON, never selected this
        // session), seed from the previous tab — but deep-clone so we don't
        // share TellTarget with the previous tab. Without the clone, a later
        // /tell on the new tab would mutate the pinned tab's TellTarget and
        // the Party/Linkshell channel would pop back to the pinned tell-mark.
        if (newTab.Channel is not null)
        {
            newTab.CurrentChannel.Channel = newTab.Channel.Value;
        }
        else if (newTab.CurrentChannel.Channel is InputChannel.Invalid)
        {
            newTab.CurrentChannel = previousTab.CurrentChannel.Clone();
            _logger.LogDebug(
                $"[Tab] '{newTab.Name}' seeded channel from '{previousTab.Name}' "
                    + $"(Channel={newTab.CurrentChannel.Channel}, TellTarget={newTab.CurrentChannel.TellTarget?.ToTargetString() ?? "null"})"
            );
        }

        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;
            _logger.LogTrace("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;
            _logger.LogTrace("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;
                _logger.LogTrace("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
        )
        {
            _logger.LogTrace($"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;
            _logger.LogTrace("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;
            _logger.LogTrace("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;

        // BgAlpha wird auf den Style-WindowBg-Alpha aus HellionStyle.PushGlobal
        // multipliziert (HellionStyle pusht eine voll-deckende Theme-Color, der
        // tatsächliche transparent-Effekt entsteht über BgAlpha). Wenn der User
        // im Dalamud-Pinning-Menü (Hamburger oben rechts) eine eigene
        // Window-Deckkraft eingestellt hat, hat dieses Per-Window-Override
        // Vorrang über unseren Slider — wir dokumentieren das im HelpMarker.
        if (LastViewport == ImGuiHelpers.MainViewport.Handle && !WasDocked)
        {
            // UI-12: focus-dependent opacity. PreOpenCheck runs before Begin();
            // Window.IsFocused holds last frame's RootAndChildWindows focus, set
            // by Dalamud's WindowHost after Begin(). One-frame latency is
            // accepted.
            BgAlpha = IsFocused ? Plugin.Config.WindowOpacity : Plugin.Config.WindowOpacityInactive;
        }

        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;
    }

    // v1.4.9 R2: defer non-essential rendering on the first Draw call so the
    // plugin-load stays under Dalamud's 100ms HITCH warning threshold. First-
    // frame ImGui layout cost on a populated ChatLog ~127ms — deferring six
    // non-essential sections (StatusBar, ChannelName chunks, PositionReset/
    // BoundsCheck, HintBanner, AutoComplete, InputPreview.CalculatePreview)
    // shaves ~33ms down to ~94ms. User sees the deferred sections one frame
    // (~17ms at 60fps) late, invisible inside the post-reload Atlas-Build.
    private bool _firstFrameDone;

    // Set when the user clicks the scroll-to-bottom button; the next
    // frame's scroll-snap check forces a jump to the live end.
    private bool _scrollToBottomRequested;

    // Cached each frame inside the ##chat2-messages child. True when the
    // user has scrolled up enough that the toolbar button should be shown.
    private bool _childScrolledUp;

    public override void Draw()
    {
        DrewThisFrame = true;
        try
        {
            DrawChatLog();
            AddPopOutsToDraw();

            // v1.4.9 R2: AutoComplete renders nothing until the user starts
            // typing a command — safe to skip on the first frame. ~6ms.
            if (_firstFrameDone)
                DrawAutoComplete();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error drawing Chat Log window");
            if (!NotifiedDrawFailure)
            {
                Plugin.Notification.AddNotification(
                    new Dalamud.Interface.ImGuiNotification.Notification
                    {
                        Title = "Hellion Chat",
                        Content = "A drawing error occurred. Check /xllog for details.",
                        Type = Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
                        InitialDuration = TimeSpan.FromSeconds(20),
                    }
                );
                NotifiedDrawFailure = true;
            }
            // Prevent recurring draw failures from constantly trying to grab
            // input focus, which breaks every other ImGui window.
            Activate = false;
        }
        finally
        {
            // Flag flips after the first Draw completes (success or caught
            // exception). Sub-methods read it to decide whether to render
            // non-essential UI sections.
            _firstFrameDone = true;
        }
    }

    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();

        // v1.4.9 R2: skip the bounds-check chain on the first frame. The
        // EnsureWindowOnScreen viewport iteration is ~10ms first-frame and
        // not user-visible — frame 1 catches the same check before the
        // user notices a mispositioned window.
        if (_firstFrameDone)
        {
            // Manual reset snaps unconditionally; on-load check only fires when the
            // stored position has no overlap with any visible viewport.
            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();

        // v1.4.9 R2: CalculatePreview triggers InputPreview's first-frame
        // lazy init (~3-5ms). User-typing-driven, safe to defer one frame.
        if (_firstFrameDone && IsChatMode && Plugin.InputPreview.IsDrawable)
            Plugin.InputPreview.CalculatePreview();

        // Render the hint banner first so it sits above the tab area at full
        // window width. ImGui accounts for its height automatically.
        // v1.4.9 R2: skip on first frame (~3-5ms layout cost). The banner
        // is a v0.6.1 migration notice that returns the same result frame 1.
        if (_firstFrameDone)
            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);
        }

        // inputColour computed up front so the channel selector button can share it.
        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;

        // Symbol-picker trigger sits left of the channel indicator. ImRaii.Popup
        // inside DrawAndConsume pins to the last rendered item, so the call MUST
        // run immediately after this IconButton — placing it after the channel
        // picker below would pin the popup under the wrong widget.
        if (Plugin.Config.SymbolPickerEnabled)
        {
            if (
                ImGuiUtil.IconButton(
                    FontAwesomeIcon.Smile,
                    "symbol-picker-trigger",
                    "Insert symbol or FFXIV icon"
                )
            )
            {
                _symbolPicker.OpenPopup();
            }
        }
        // DrawAndConsume runs unconditionally; with the button hidden the popup
        // can't open, so the call is a no-op. Splice path stays outside the
        // guard for the same reason.
        var insertedSymbol = _symbolPicker.DrawAndConsume();
        if (insertedSymbol is not null)
        {
            // Same cursor-aware splice idiom as the AutoComplete commit path at
            // ChatLogWindow.cs:2487-2493. Clamp because CursorPos can drift if
            // the user mutates Chat while the popup is open.
            var pos = Math.Clamp(CursorPos, 0, Chat.Length);
            Chat = Chat[..pos] + insertedSymbol + Chat[pos..];
            Activate = true;
            ActivatePos = pos + insertedSymbol.Length;
        }
        if (Plugin.Config.SymbolPickerEnabled)
            ImGui.SameLine();

        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);
        // Right-side buttons: quick-picker palette + cog (always present)
        // plus the optional hide / novice buttons. Each slot costs the
        // measured button width AND one ItemSpacing for the SameLine gap
        // in front of it -- leaving the spacing term out overflows the
        // header row by one gap per button (v1.5.4 quick-picker fix).
        var rightButtonCount = 2 + buttonsRight;
        var inputWidth =
            ImGui.GetContentRegionAvail().X
            - rightButtonCount * (buttonWidth + ImGui.GetStyle().ItemSpacing.X);

        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;

                    // UI-11: Escape cancels the input — drop any pending
                    // disclosure arming so the warning does not linger.
                    _disclosureArmedBufferMain = null;

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

                if (ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter))
                {
                    if (
                        Plugin.Config.NotifyPluginDisclosure
                        && Chat != _disclosureArmedBufferMain
                        && PluginDisclosureScanner.ContainsPrivateUseGlyph(Chat)
                    )
                    {
                        // First send attempt on this exact buffer: arm and hold.
                        // The warning renders below the input.
                        _disclosureArmedBufferMain = Chat;
                    }
                    else
                    {
                        _disclosureArmedBufferMain = null;
                        Plugin.CommandHelpWindow.IsOpen = false;
                        SendChatBox(activeTab);

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

            // UI-11: disclosure warning for the main-window input, mirrors the
            // ChatInputBar path. Visible only while the armed buffer is held
            // unchanged; editing the buffer clears the condition.
            if (
                Plugin.Config.NotifyPluginDisclosure
                && _disclosureArmedBufferMain is not null
                && Chat == _disclosureArmedBufferMain
            )
            {
                ImGui.TextColored(
                    ImGuiColors.DalamudYellow,
                    HellionStrings.ChatInput_PluginDisclosure_Warning
                );
            }

            // 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();

                    // Insert game text-macro tokens. The game expands <flag>/<item> at
                    // send time, so inserting literal token text is enough. Each entry is
                    // disabled when its precondition is unmet (no map flag, no linked item)
                    // so the inserted token cannot expand to nothing.
                    unsafe
                    {
                        // Null-check before deref: pointers can be null during zone transitions.
                        var agentMap = AgentMap.Instance();
                        var flagSet = agentMap != null && agentMap->FlagMarkerCount > 0;
                        using (ImRaii.Disabled(!flagSet))
                        {
                            if (ImGui.Selectable(HellionStrings.ChatLog_Insert_MapFlag))
                            {
                                Chat += "<flag>";
                                Activate = true;
                                ActivatePos = Chat.Length;
                            }
                        }

                        var agentChat = AgentChatLog.Instance();
                        var itemSet = agentChat != null && agentChat->LinkedItem.ItemId != 0;
                        using (ImRaii.Disabled(!itemSet))
                        {
                            if (ImGui.Selectable(HellionStrings.ChatLog_Insert_ItemLink))
                            {
                                Chat += "<item>";
                                Activate = true;
                                ActivatePos = Chat.Length;
                            }
                        }
                    }
                }
            }
        }

        ImGui.SameLine();

        if (
            ImGuiUtil.IconButton(
                FontAwesomeIcon.Palette,
                tooltip: HellionStrings.Settings_QuickPicker_Tooltip,
                width: (int)buttonWidth
            )
        )
            ImGui.OpenPopup("##hellion-quick-picker");

        DrawQuickPickerPopup();

        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)
        {
            ImGui.SameLine();

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

        // v1.2.0 — Bottom-Status-Bar. Letzter Render-Step in DrawChatLog,
        // damit alle Zeilen-Operationen davor keine Layout-Sprünge auslösen.
        // v1.4.9 R2: skip on the first frame; ~12ms of first-frame layout
        // cost. User sees the StatusBar 1 frame (~17ms at 60fps) later
        // which is hidden inside the post-reload Atlas-Build window.
        if (_firstFrameDone)
            Plugin.StatusBar.Draw(Plugin);
    }

    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)
    {
        // v1.4.9 R2: plain-text fallback on the first frame. ReadChannelName
        // builds SeString chunks and DrawChunks runs SeString-Renderer layout
        // — together ~18ms first-frame. Frame 1 renders the real chunks; the
        // user sees the tab name for ~17ms during the post-reload window.
        if (!_firstFrameDone)
        {
            ImGui.TextUnformatted(activeTab.Name);
            return;
        }

        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
            {
                // ExtraChat channel names aren't available over IPC by index,
                // so we skip the name lookup and show the short form instead.
                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;
        }

        // ExtraChat linkshell channel switch: call the prefix command through the
        // game chat because ExtraChat only registers stub handlers in Dalamud.
        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),
        ];
    }

    // Pop-out windows route submission here. The main Chat buffer is briefly
    // used as a vehicle for SendChatBox and restored afterwards.
    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: can't send directly, so format as /tell 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,
        bool updateScrollState = true
    )
    {
        using (var child = ImRaii.Child("##chat2-messages", new Vector2(-1, childHeight)))
        {
            if (child.Success)
            {
                if (tab.DisplayTimestamp && Plugin.Config.PrettierTimestamps)
                    DrawLogTableStyle(tab, handler, switchedTab);
                else
                    DrawLogNormalStyle(tab, handler, switchedTab);

                // Cached for the header toolbar's scroll-to-bottom button, which is
                // drawn one frame later. GetScrollMaxY / GetScrollY here refer to
                // the child's scroll context. Pop-out windows pass updateScrollState:
                // false so they do not overwrite the main window's cached state.
                if (updateScrollState)
                    _childScrolledUp = ImGui.GetScrollMaxY() - ImGui.GetScrollY() > 1f;
            }
            else
            {
                if (updateScrollState)
                    _childScrolledUp = false;
            }
        }
    }

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

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

        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
                    || _scrollToBottomRequested
                    || ImGui.GetScrollY() + cellPaddingOffset >= ImGui.GetScrollMaxY()
                )
                    ImGui.SetScrollHereY(1f);
                _scrollToBottomRequested = false;

                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;

            // Card-mode pre-loop: theme/drawList/winLeft/winRight are
            // invariant per DrawMessages call. borderColorAbgr used to be
            // hoisted here too, but PM-3d (v1.5.4) modulates it by
            // tab._cardHoverAlpha per row, so it moves into the AddLine
            // call below. anyCardHovered aggregates the row-hover state
            // across all card-rows; the lerp runs once at the loop end so
            // the next frame paints with the updated alpha.
            var theme = Plugin.ThemeRegistry.Active;
            var drawList = ImGui.GetWindowDrawList();
            var winLeft = ImGui.GetWindowPos().X;
            var winRight = winLeft + ImGui.GetWindowSize().X;
            var baseBorderRgba = (theme.Colors.Border & 0xFFFFFF00u) | 0x33u;
            var anyCardHovered = false;

            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;

                // v1.2.0 card mode: sender on its own line in channel color, then body,
                // then a subtle border as a card separator.
                // Compact mode: sender + space + content on one line via SameLine.
                var useCard = !Plugin.Config.UseCompactDensity;
                if (useCard)
                {
                    var rowStartY = ImGui.GetCursorScreenPos().Y;

                    if (message.Sender.Count > 0)
                    {
                        var senderColor =
                            Plugin.Functions.Chat.GetChannelColor(message.Code.Type)
                            ?? theme.Colors.TextPrimary;
                        using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(senderColor)))
                        {
                            DrawChunks(message.Sender, true, handler, lineWidth);
                        }
                        // No SameLine — body renders on its own line.
                    }

                    // 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);

                    // Border bottom as card separator. Base alpha 0x33;
                    // PM-3d lifts it by up to ~+0x70 while any row in this
                    // tab is hovered. _cardHoverAlpha lerps at the loop
                    // end, so the one-frame lag is invisible at 10f speed.
                    {
                        var rowEndY = ImGui.GetCursorScreenPos().Y;
                        var hoverBoost = 0.45f * tab._cardHoverAlpha;
                        var alphaByte = (uint)
                            Math.Clamp((int)(0x33u + hoverBoost * 255f), 0x33, 0xCC);
                        var borderColorAbgr = ColourUtil.RgbaToAbgr(
                            (baseBorderRgba & 0xFFFFFF00u) | alphaByte
                        );
                        drawList.AddLine(
                            new Vector2(winLeft + 4, rowEndY - 1),
                            new Vector2(winRight - 4, rowEndY - 1),
                            borderColorAbgr,
                            1f
                        );
                        ImGui.Dummy(new Vector2(0, 2));

                        // Whole-row hover test. IsItemHovered would only see
                        // the 2px Dummy above, so hit-test the row rect from
                        // its start Y down to the separator line instead.
                        if (
                            ImGui.IsMouseHoveringRect(
                                new Vector2(winLeft, rowStartY),
                                new Vector2(winRight, rowEndY)
                            )
                        )
                            anyCardHovered = true;
                    }
                }
                else
                {
                    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();
            }

            // PM-3d: update the per-tab card-hover lerp once per
            // DrawMessages call. ReduceMotion snaps to the target;
            // otherwise the border alpha eases toward it over a few
            // frames the next time the rows paint.
            var cardTarget = anyCardHovered ? 1f : 0f;
            tab._cardHoverAlpha = Plugin.Config.ReduceMotion
                ? cardTarget
                : FrameLerp.Smooth(
                    tab._cardHoverAlpha,
                    cardTarget,
                    speed: 10f,
                    deltaTime: ImGui.GetIO().DeltaTime
                );
        }
        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)
        {
            _logger.LogWarning(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;

            // Active-tab underline pill (2px accent). No native ImGui underline API,
            // so we use a direct DrawList pass. Pill height scales with GlobalScale
            // and all coordinates round to physical pixels so the line stays crisp
            // on 125/150% DPI setups instead of bleeding into a sub-pixel blur.
            {
                var theme = Plugin.ThemeRegistry.Active;
                var min = ImGui.GetItemRectMin();
                var max = ImGui.GetItemRectMax();
                var pillHeight = MathF.Max(1f, MathF.Round(2f * ImGuiHelpers.GlobalScale));
                var yBottom = MathF.Round(max.Y);
                var yTop = yBottom - pillHeight;
                ImGui
                    .GetWindowDrawList()
                    .AddRectFilled(
                        new Vector2(MathF.Round(min.X), yTop),
                        new Vector2(MathF.Round(max.X), yBottom),
                        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;
    }

    // Sidebar render order: persistent tabs in their original Plugin.Config.Tabs
    // position, then pinned TempTabs, then unpinned TempTabs. Returns indices
    // into Plugin.Config.Tabs so tabI in the loop body still mirrors the real
    // list position (LastTab / WantedTab stay consistent).
    private static List<int> BuildSidebarRenderOrder()
    {
        var tabs = Plugin.Config.Tabs;
        var persistent = new List<int>(tabs.Count);
        var pinned = new List<int>();
        var unpinned = new List<int>();
        for (var i = 0; i < tabs.Count; i++)
        {
            if (TabLifecycleHelpers.IsInPinnedPool(tabs[i]))
                pinned.Add(i);
            else if (TabLifecycleHelpers.IsInUnpinnedPool(tabs[i]))
                unpinned.Add(i);
            else
                persistent.Add(i);
        }
        persistent.AddRange(pinned);
        persistent.AddRange(unpinned);
        return persistent;
    }

    private void DrawTabSidebar()
    {
        var currentTab = -1;
        // Sidebar fixed at 44px, no resize.
        using var tabTable = ImRaii.Table(
            "tabs-table",
            2,
            ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingFixedFit
        );
        if (!tabTable.Success)
            return;

        var sidebarWidth = Math.Clamp(Plugin.Config.SidebarWidth, 44, 160);
        ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, sidebarWidth);
        ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.WidthStretch, 1);

        ImGui.TableNextColumn();

        var hasTabSwitched = false;
        var childHeight = GetRemainingHeightForMessageLog();
        // Sidebar child without ChildBg tint to avoid a colored block above the
        // header toolbar area. Vertical separation is handled by BordersInnerV.
        using (ImRaii.PushColor(ImGuiCol.ChildBg, 0u))
        using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight)))
        {
            if (child)
            {
                // Top padding mirrors the HeaderToolbar height so sidebar buttons
                // align with the message log start.
                ImGui.Dummy(new Vector2(0, ImGui.GetFrameHeightWithSpacing()));

                var previousTab = Plugin.CurrentTab;
                // Render order: persistent → pinned TempTabs → unpinned TempTabs.
                // Underlying Plugin.Config.Tabs order is untouched (tabI mirrors
                // the real list index), only the display sequence groups by
                // section so each section can carry its own divider header.
                var renderOrder = BuildSidebarRenderOrder();
                var pinnedHeaderRendered = false;
                var tempTabHeaderRendered = false;
                var pinnedCount = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
                var unpinnedTempCount = Plugin.Config.Tabs.Count(
                    TabLifecycleHelpers.IsInUnpinnedPool
                );

                foreach (var tabI in renderOrder)
                {
                    var tab = Plugin.Config.Tabs[tabI];
                    if (tab.PopOut)
                        continue;

                    if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered)
                    {
                        ImGui.Separator();
                        if (!Plugin.Config.AutoTellTabsCompactDisplay)
                        {
                            ImGui.TextDisabled(
                                $"{HellionStrings.PinTab_SectionHeader} ({pinnedCount})"
                            );
                        }
                        pinnedHeaderRendered = true;
                    }
                    else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !tempTabHeaderRendered)
                    {
                        ImGui.Separator();
                        if (!Plugin.Config.AutoTellTabsCompactDisplay)
                        {
                            ImGui.TextDisabled(
                                $"{HellionStrings.AutoTellTabs_SectionHeader} ({unpinnedTempCount})"
                            );
                        }
                        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 left of the selectable to keep click areas separate.
                        // Compact padding keeps the icon next to the tab name.
                        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();
                    }

                    // Icon-only sidebar with tooltip on hover. Active tab gets accent color;
                    // greeted tabs are dimmed; tell tabs get a hash-based tint.
                    var theme = Plugin.ThemeRegistry.Active;
                    var icon = TabIconMapping.Resolve(tab);
                    uint iconColor;
                    if (isCurrentTab)
                    {
                        iconColor = theme.Colors.Accent;
                    }
                    else if (showGreetedAffordance && tab.IsGreeted)
                    {
                        iconColor = theme.Colors.TextDim;
                    }
                    else if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet())
                    {
                        // Hash-based color tint differentiates parallel Auto-Tell tabs
                        // without requiring manual icon assignment per tab.
                        iconColor = TabTintCache.GetTint(tab);
                    }
                    else
                    {
                        iconColor = 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)
                        )
                    )
                    // PM-3c: icon alpha eases from 40% (dim) to 100% on
                    // hover. _hoverAlpha lerps at the end of this block,
                    // so the colour for frame N uses frame N-1's value --
                    // a sub-frame lag that is invisible at 10f speed.
                    using (
                        ImRaii.PushColor(
                            ImGuiCol.Text,
                            ColourUtil.ApplyAlpha(
                                ColourUtil.RgbaToAbgr(iconColor),
                                0.4f + 0.6f * tab._hoverAlpha
                            )
                        )
                    )
                    using (Plugin.FontManager.FontAwesome.Push())
                    {
                        // Button stretches with the configured sidebar width so a
                        // user-widened sidebar feels intentional, not a 36px icon
                        // floating in empty space.
                        clicked = ImGui.Button(
                            $"{icon.ToIconString()}##sidebar-tab-{tabI}",
                            new Vector2(sidebarWidth - 8f, ImGui.GetFrameHeight())
                        );
                    }

                    // PM-3c hover-lerp: ramp _hoverAlpha toward 1 while the
                    // icon button is hovered, back to 0 otherwise.
                    // ReduceMotion snaps so the dim/full states stay binary.
                    var hoverTarget = ImGui.IsItemHovered() ? 1f : 0f;
                    tab._hoverAlpha = Plugin.Config.ReduceMotion
                        ? hoverTarget
                        : FrameLerp.Smooth(
                            tab._hoverAlpha,
                            hoverTarget,
                            speed: 10f,
                            deltaTime: ImGui.GetIO().DeltaTime
                        );

                    if (isCurrentTab)
                    {
                        // Vertical accent pill on the left window edge, 3px wide, half tab height,
                        // vertically centered. Direct DrawList pass, no native ImGui API for this.
                        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
                    }

                    // Unread dot top-right of the icon. Active tabs have Unread=0 by convention
                    // so the dot never conflicts with the active pill.
                    if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0)
                    {
                        var min = ImGui.GetItemRectMin();
                        var max = ImGui.GetItemRectMax();
                        const float dotRadius = 4f;
                        const float dotPadding = 3f;
                        var dotCenter = new Vector2(
                            max.X - dotRadius - dotPadding,
                            min.Y + dotRadius + dotPadding
                        );

                        // Sin-based 2s pulse: alpha oscillates 60-100%. Skipped when ReduceMotion is on.
                        var dotColor = theme.Colors.StatusDanger;
                        if (!Plugin.Config.ReduceMotion)
                        {
                            // Sin-basierter 2s-Cycle: -1..1 → 0..1 → 0.6..1.0 Alpha-Skala.
                            var phase = (float)(
                                (Math.Sin(Environment.TickCount64 / 1000.0 * Math.PI) + 1.0) * 0.5
                            );
                            var alphaScale = 0.6f + 0.4f * phase;
                            var origAlpha = dotColor & 0xFFu;
                            var pulsedAlpha = (uint)(origAlpha * alphaScale);
                            dotColor = (dotColor & 0xFFFFFF00u) | pulsedAlpha;
                        }

                        ImGui
                            .GetWindowDrawList()
                            .AddCircleFilled(
                                dotCenter,
                                dotRadius,
                                ColourUtil.RgbaToAbgr(dotColor),
                                12
                            );
                    }

                    // Pin indicator: subtle thumbtack glyph top-left of the icon.
                    // Muted colour because the "Pinned" section header already
                    // groups these tabs visually — this is just a per-tab
                    // confirmation glyph, not the primary discoverability cue.
                    if (tab.IsPinned)
                    {
                        var min = ImGui.GetItemRectMin();
                        const float pinPadding = 1f;
                        var pinPos = new Vector2(min.X + pinPadding, min.Y + pinPadding);
                        var pinColor = theme.Colors.TextMuted;
                        // Dim further so the glyph reads as a hint, not a badge.
                        var pinAbgr = ColourUtil.RgbaToAbgr(pinColor) & 0x77FFFFFFu;
                        using (Plugin.FontManager.FontAwesome.Push())
                        {
                            ImGui
                                .GetWindowDrawList()
                                .AddText(pinPos, pinAbgr, FontAwesomeIcon.Thumbtack.ToIconString());
                        }
                    }

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

                    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;
    }

    // DrawChatHeaderToolbar: renders the honorific title slot, the optional
    // scroll-to-bottom button, and the pop-out button for the active tab.
    private void DrawChatHeaderToolbar(Tab tab)
    {
        DrawHonorificTitleSlot();
        DrawScrollToBottomToolbarButton();
        DrawPopOutButton(tab);
    }

    // Draws an arrow-down button in the toolbar when the user has scrolled up
    // from the live end of the chat log. Clicking it requests a snap to bottom.
    //
    // _childScrolledUp is set at the end of DrawMessageLog, which runs AFTER
    // DrawChatHeaderToolbar in the same frame. So this button always reflects the
    // previous frame's scroll state, a one-frame lag that is imperceptible in use.
    //
    // Both this button and DrawPopOutButton use SetCursorPosX with absolute
    // positioning (cursorX + GetContentRegionAvail().X - N * iconWidth). Because
    // each call computes its own target X from the right edge, they are independent
    // of each other and of what the cursor position happens to be at call time.
    // The pop-out button lands at rightEdge - iconWidth regardless of call order.
    private void DrawScrollToBottomToolbarButton()
    {
        if (!_childScrolledUp)
            return;

        var avail = ImGui.GetContentRegionAvail().X;
        var iconWidth = ImGui.GetFrameHeight();
        var spacing = ImGui.GetStyle().ItemSpacing.X;
        ImGui.SetCursorPosX(ImGui.GetCursorPosX() + avail - 2 * iconWidth - spacing);

        if (
            ImGuiUtil.IconButton(
                FontAwesomeIcon.ArrowDown,
                tooltip: HellionStrings.ChatLog_ScrollToBottom_Tooltip
            )
        )
            _scrollToBottomRequested = true;

        // Keep the pop-out button on the same toolbar row. Without this the
        // button item ends the line and the pop-out drops to the next row.
        ImGui.SameLine();
    }

    private void DrawPopOutButton(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();
        }
    }

    // Title rendered first so DrawPopOutButton can anchor flush right via
    // GetContentRegionAvail. Call order in DrawChatHeaderToolbar matters.
    // SameLine keeps both on the same toolbar row.
    private void DrawHonorificTitleSlot()
    {
        var service = Plugin.HonorificService;
        var title = service.CurrentTitle;
        if (
            !HonorificService.ShouldRenderSlot(
                Plugin.Config.ShowHonorificTitleInHeader,
                service.IsAvailable,
                title
            )
        )
        {
            return;
        }

        // Reserve space for the crown icon plus a small gap before the title,
        // then the title itself, then the gap-to-pop-out-button. We measure the
        // crown width inside the FontAwesome font push because FontAwesome
        // glyphs render in a different font than the regular ImGui text.
        const float gapAfterCrown = 4f;
        const float gapBeforeButton = 8f;
        var avail = ImGui.GetContentRegionAvail().X;
        var iconWidth = ImGui.GetFrameHeight();

        float crownWidth;
        using (Plugin.FontManager.FontAwesome.Push())
        {
            crownWidth = ImGui.CalcTextSize(FontAwesomeIcon.Crown.ToIconString()).X;
        }

        // When the scroll button is also present it occupies iconWidth + ItemSpacing.X
        // to the left of the pop-out button, so shrink the title budget accordingly.
        var scrollButtonReserve = _childScrolledUp
            ? iconWidth + ImGui.GetStyle().ItemSpacing.X
            : 0f;
        var maxTitleWidth =
            avail - iconWidth - scrollButtonReserve - gapBeforeButton - crownWidth - gapAfterCrown;
        if (maxTitleWidth <= 0)
        {
            return;
        }

        var rendered = "«" + title!.Title + "»";
        rendered = StringUtil.TruncateToFitWidth(rendered, maxTitleWidth);

        var titleColor = title.Color is { } c
            ? new Vector4(c.X, c.Y, c.Z, 1f)
            : ImGui.GetStyle().Colors[(int)ImGuiCol.Text];

        var theme = Plugin.ThemeRegistry.Active;

        // Group so IsItemHovered covers both the crown icon and the title text.
        ImGui.BeginGroup();
        using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted)))
        using (Plugin.FontManager.FontAwesome.Push())
        {
            ImGui.TextUnformatted(FontAwesomeIcon.Crown.ToIconString());
        }
        ImGui.SameLine(0f, gapAfterCrown);
        DrawHonorificTitleText(rendered, titleColor, title.Glow);
        ImGui.EndGroup();

        if (ImGui.IsItemHovered())
        {
            ImGui.SetTooltip(HellionStrings.ChatHeader_HonorificTitle_Tooltip);
        }

        ImGui.SameLine();
    }

    // Renders the title text, optionally with a glow outline pre-pass. Glow is
    // drawn at 8 cardinal offsets (±1 px) in the glow colour at reduced alpha,
    // then the primary text on top. The pre-pass uses the window draw list so
    // it composites correctly with the regular ImGui text that follows.
    private void DrawHonorificTitleText(string rendered, Vector4 titleColor, Vector3? glow)
    {
        if (Plugin.Config.ShowHonorificGlow && glow is { } g)
        {
            var pos = ImGui.GetCursorScreenPos();
            var glowColor = new Vector4(g.X, g.Y, g.Z, 0.4f);
            var glowAbgr = ImGui.ColorConvertFloat4ToU32(glowColor);
            var drawList = ImGui.GetWindowDrawList();
            for (var dy = -1; dy <= 1; dy++)
            {
                for (var dx = -1; dx <= 1; dx++)
                {
                    if (dx == 0 && dy == 0)
                        continue;
                    drawList.AddText(new Vector2(pos.X + dx, pos.Y + dy), glowAbgr, rendered);
                }
            }
        }

        using (ImRaii.PushColor(ImGuiCol.Text, titleColor))
        {
            ImGui.TextUnformatted(rendered);
        }
    }

    // One-time hint banner for the pop-out header button and right-click pathway.
    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 style stack so an early return can never leave ImGui unbalanced.
        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();
            _logger.LogDebug("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;

        // Focus the rename field on the frame the context menu opens so the
        // user can type immediately. Buffer raised 128 -> 512 to match the
        // settings-tab rename (Ui/SettingsTabs/Tabs.cs). One name limit, not two.
        if (ImGui.IsWindowAppearing())
            ImGui.SetKeyboardFocusHere();
        ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale);
        if (ImGui.InputText("##tab-name", ref tab.Name, 512))
            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 (tab.IsTempTab)
        {
            ImGui.Separator();
            DrawPinControls(tab);
        }

        if (anyChanged)
            Plugin.SaveConfig();
    }

    private void DrawPinControls(Tab tab)
    {
        var svc = Plugin.AutoTellTabsService;
        if (svc == null)
            return;

        if (tab.IsPinned)
        {
            if (ImGui.MenuItem(HellionStrings.PinTab_MenuUnpin))
            {
                svc.Unpin(tab);
                ImGui.CloseCurrentPopup();
            }
        }
        else
        {
            var atCap = svc.PinnedTempTabCount >= AutoTellTabsService.MaxPinnedTempTabs;
            if (ImGui.MenuItem(HellionStrings.PinTab_MenuPin, enabled: !atCap))
            {
                if (svc.TryPin(tab))
                    ImGui.CloseCurrentPopup();
            }
            if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
            {
                ImGui.SetTooltip(
                    atCap
                        ? string.Format(
                            HellionStrings.PinTab_LimitReached,
                            AutoTellTabsService.MaxPinnedTempTabs
                        )
                        : HellionStrings.PinTab_PinTooltip
                );
            }
        }
    }

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

    // Live enumeration of active Popout windows for KeybindManager tab-cycle forwarding.
    // Filters on IsOpen to skip closed-but-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, _loggerFactory.CreateLogger<Popout>());

            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
        {
            // Destroy frees the unmanaged ImGuiListClipper allocated above; without it the block leaks per 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
    )
    {
        // UI-7: render a copy with the sender name reformatted per the user's
        // display options. Skipped in screenshot mode so the name-anonymising
        // path in DrawChunk stays reliable (privacy wins). ForDisplay returns
        // the list unchanged when nothing applies, so non-sender lists and the
        // neutral default cost only a quick scan.
        if (!ScreenshotMode)
            chunks = SenderNameDisplay.ForDisplay(chunks);

        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: minimum window overlap with a visible viewport before
    // we consider it 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;
        _logger.LogInformation(
            $"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}."
        );

        // Pop-outs don't persist across sessions so they can never end up off-screen
        // after a reload. Only the main window needs explicit recovery.
    }
}
