using System.Collections;
using System.Linq;
using Dalamud;
using Dalamud.Bindings.ImGui;
using Dalamud.Configuration;
using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.FontIdentifier;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
using HellionChat.Util;

namespace HellionChat;

[Serializable]
public class ConfigKeyBind
{
    public ModifierFlag Modifier;
    public VirtualKey Key;

    public override string ToString()
    {
        var modString = "";
        if (Modifier.HasFlag(ModifierFlag.Ctrl))
            modString += Language.Keybind_Modifier_Ctrl + " + ";
        if (Modifier.HasFlag(ModifierFlag.Shift))
            modString += Language.Keybind_Modifier_Shift + " + ";
        if (Modifier.HasFlag(ModifierFlag.Alt))
            modString += Language.Keybind_Modifier_Alt + " + ";
        return modString + Key.GetFancyName();
    }
}

[Serializable]
public class Configuration : IPluginConfiguration
{
    private const int LatestVersion = 19;

    public int Version { get; set; } = LatestVersion;

    // Slug-based; ThemeRegistry resolves the object at runtime.
    public string Theme = "hellion-arctic";

    // Global window opacity, applied across all themes.
    public float WindowOpacity = 0.85f;

    // UI-12: background opacity of the main chat window while unfocused.
    // WindowOpacity above stays the focused value.
    public float WindowOpacityInactive = 0.65f;

    // Reserved for future UI toggles; pre-declared to avoid a migration later.
    public bool ReduceMotion;

    // v1.2.1: default flipped false → true. Compact single-line layout is
    // more readable than the card-rows layout introduced in v1.2.0.
    public bool UseCompactDensity = true;

    // Privacy by Default master switch. Set false to restore upstream behaviour.
    public bool PrivacyFilterEnabled = true;

    // Empty set means the migration has not run yet — see Plugin.cs v6→v7.
    public HashSet<ChatType> PrivacyPersistChannels = [];

    // Failsafe for ChatTypes added by future FFXIV patches. New configs default
    // to the failsafe via PrivacyDefaults; existing configs keep their saved
    // choice because the deserializer overrides this initializer.
    public bool PrivacyPersistUnknownChannels = Privacy
        .PrivacyDefaults
        .DefaultPersistUnknownChannels;

    // F3.2: dedup unknown-ChatType warnings so a chatty filter doesn't spam
    // the log every frame. NonSerialized so the warning fires once per
    // runtime, not once-ever-per-install.
    [NonSerialized]
    private readonly HashSet<ChatType> _warnedUnknownChannels = new();

    public bool IsAllowedForStorage(ChatType type)
    {
        if (!PrivacyFilterEnabled)
            return true;
        if (PrivacyPersistChannels.Contains(type))
            return true;

        // F3.2: log first occurrence of a ChatType the running build doesn't
        // recognise — i.e. one a future FFXIV patch may have added. Known
        // types the user opted out of are routed through the failsafe
        // silently, like before.
        if (!Enum.IsDefined(typeof(ChatType), type) && _warnedUnknownChannels.Add(type))
        {
            Plugin.LogProxy.Warning(
                "PrivacyFilter: unrecognised ChatType {Type} — falling back to PrivacyPersistUnknownChannels={Persist}.",
                type,
                PrivacyPersistUnknownChannels
            );
        }

        return PrivacyPersistUnknownChannels;
    }

    // Retention master switch defaults to false — plugin will not delete
    // history until the user explicitly opts in.
    public bool RetentionEnabled;
    public int RetentionDefaultDays = 30;
    public Dictionary<ChatType, int> RetentionPerChannelDays = [];
    public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue;
    public bool FirstRunCompleted;

    // Tracks which plugin version last surfaced the first-run wizard.
    // When the running version is newer than this, Plugin.LoadAsync
    // re-opens the wizard once so existing users see major UX reworks
    // (e.g. the v1.5.2 multi-step rewrite). Skip path and Finish both
    // set FirstRunCompleted = true on close, so the wizard only fires
    // once per version bump even if the user dismisses it.
    public string WizardLastShownVersion = string.Empty;

    public bool UseHellionFont = true;
    public bool ShowHonorificTitleInHeader = true;

    // v1.4.7 opt-in: renders the Honorific glow outline when the title carries
    // a Glow colour. Default OFF — keeps v1.4.6 visuals untouched for users
    // who don't care, and dodges the per-frame DrawList overhead on low-end
    // hardware. Gradient (Color3 / GradientColourSet) is parsed but rendered
    // as the primary Color until a later cycle ports the animation.
    public bool ShowHonorificGlow;
    public bool EnableAutoTellTabs = true;
    public int AutoTellTabsLimit = 15;
    public bool AutoTellTabsCompactDisplay;
    public int AutoTellTabsHistoryPreload = 20;

    // Sidebar width in pixels. Default 44 mirrors the icon-only layout from
    // v1.2.0; users can widen up to 160 to fit a section-header line like
    // "Active Tells (3)" without truncation.
    public int SidebarWidth = 44;
    public bool AutoTellTabsShowGreetedToggle;
    public bool SeenPopOutInputHint;
    public bool PopOutInputEnabled = true;
    public bool SeenPopOutHeaderHint;
    public bool AutoTellTabsOpenAsPopout;

    // UI-7: how sender names are rendered in the chat log.
    public WorldSuffixMode WorldSuffixMode = WorldSuffixMode.OtherWorldOnly;
    public NameFormMode NameFormMode = NameFormMode.Full;

    public int GetRetentionDays(ChatType type)
    {
        if (RetentionPerChannelDays.TryGetValue(type, out var userOverride))
            return userOverride;
        if (Privacy.PrivacyDefaults.DefaultRetentionDays.TryGetValue(type, out var specDefault))
            return specDefault;
        return RetentionDefaultDays;
    }

    public bool HideChat = true;
    public bool HideDuringCutscenes = true;
    public bool HideWhenNotLoggedIn = true;
    public bool HideWhenUiHidden = true;
    public bool HideInLoadingScreens;
    public bool HideInBattle;

    // v1.2.1: default flipped false → true for consistency with other hide defaults.
    public bool HideInNewGamePlusMenu = true;
    public bool HideWhenInactive;
    public int InactivityHideTimeout = 10;
    public bool InactivityHideActiveDuringBattle = true;

    [Obsolete("Use InactivityHideChannelsV2 instead")]
    public Dictionary<ChatType, ChatSource> InactivityHideChannels = [];

    public Dictionary<ChatType, (ChatSource, ChatSource)> InactivityHideChannelsV2 = [];
    public bool InactivityHideExtraChatAll = true;
    public HashSet<Guid> InactivityHideExtraChatChannels = [];
    public bool ShowHideButton = true;
    public bool NativeItemTooltips = true;
    public bool PrettierTimestamps = true;
    public bool MoreCompactPretty;
    public bool HideSameTimestamps = true;
    public bool ShowNoviceNetwork;
    public bool SidebarTabView = true;
    public bool PrintChangelog = true;
    public bool OnlyPreviewIf;
    public int PreviewMinimum = 1;
    public PreviewPosition PreviewPosition = PreviewPosition.Inside;
    public CommandHelpSide CommandHelpSide = CommandHelpSide.None;
    public KeybindMode KeybindMode = KeybindMode.Strict;
    public LanguageOverride LanguageOverride = LanguageOverride.None;
    public bool CanMove = true;
    public bool CanResize = true;
    public bool ShowTitleBar = true;
    public bool ShowPopOutTitleBar = true;
    public bool DatabaseBattleMessages;
    public bool LoadPreviousSession;
    public bool FilterIncludePreviousSessions;
    public bool SortAutoTranslate;
    public bool CollapseDuplicateMessages;
    public bool CollapseKeepUniqueLinks;
    public bool SymbolPickerEnabled = true;
    public bool PlaySounds = true;

    // AUDIO-1: playback volume (0-1) for the three bundled custom sounds.
    public float CustomSoundVolume = 0.5f;

    // Toast when a tell the user sent could not be delivered.
    public bool NotifyFailedTell = true;

    // UI-11: warn before sending a message that carries plugin-only glyphs.
    public bool NotifyPluginDisclosure = true;
    public bool KeepInputFocus = true;
    public int MaxLinesToRender = 2_500; // 1-10000
    public bool Use24HourClock = true;
    public bool ShowEmotes = true;
    public HashSet<string> BlockedEmotes = [];
    public bool FontsEnabled = true;
    public ExtraGlyphRanges ExtraGlyphRanges = 0;
    public float FontSizeV2 = 12.75f;
    public float SymbolsFontSizeV2 = 12.75f;
    public SingleFontSpec GlobalFontV2 = new()
    {
        // dalamud only ships KR as regular, which chat2 used previously for global fonts
        FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular),
        SizePt = 12.75f,
    };
    public SingleFontSpec JapaneseFontV2 = new()
    {
        FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkMedium),
        SizePt = 12.75f,
    };
    public bool ItalicEnabled;
    public SingleFontSpec ItalicFontV2 = new()
    {
        FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular),
        SizePt = 12.75f,
    };

    public float TooltipOffset;

    public Dictionary<ChatType, uint> ChatColours = BuildDefaultChatColours();

    private static Dictionary<ChatType, uint> BuildDefaultChatColours()
    {
        var defaults = new Dictionary<ChatType, uint>();
        foreach (
            var (channel, colour) in HellionChat.Resources.ChatColourPresets.All["Hellion"].Colours
        )
            defaults[channel] = colour;
        return defaults;
    }

    public bool ColorSelectedInputChannelButton = true;
    public List<Tab> Tabs = [];

    public ConfigKeyBind? ChatTabForward;
    public ConfigKeyBind? ChatTabBackward;

    public void UpdateFrom(Configuration other, bool backToOriginal)
    {
        if (backToOriginal)
            foreach (var tab in Tabs.Where(t => t.PopOut))
                tab.PopOut = false;

        HideChat = other.HideChat;
        HideDuringCutscenes = other.HideDuringCutscenes;
        HideWhenNotLoggedIn = other.HideWhenNotLoggedIn;
        HideWhenUiHidden = other.HideWhenUiHidden;
        HideInLoadingScreens = other.HideInLoadingScreens;
        HideInBattle = other.HideInBattle;
        HideInNewGamePlusMenu = other.HideInNewGamePlusMenu;
        HideWhenInactive = other.HideWhenInactive;
        InactivityHideTimeout = other.InactivityHideTimeout;
        InactivityHideActiveDuringBattle = other.InactivityHideActiveDuringBattle;
        InactivityHideChannelsV2 = other.InactivityHideChannelsV2.ToDictionary(
            pair => pair.Key,
            pair => pair.Value
        );
        InactivityHideExtraChatAll = other.InactivityHideExtraChatAll;
        InactivityHideExtraChatChannels = other.InactivityHideExtraChatChannels.ToHashSet();
        ShowHideButton = other.ShowHideButton;
        NativeItemTooltips = other.NativeItemTooltips;
        PrettierTimestamps = other.PrettierTimestamps;
        MoreCompactPretty = other.MoreCompactPretty;
        HideSameTimestamps = other.HideSameTimestamps;
        ShowNoviceNetwork = other.ShowNoviceNetwork;
        SidebarTabView = other.SidebarTabView;
        PrintChangelog = other.PrintChangelog;
        OnlyPreviewIf = other.OnlyPreviewIf;
        PreviewMinimum = other.PreviewMinimum;
        PreviewPosition = other.PreviewPosition;
        CommandHelpSide = other.CommandHelpSide;
        KeybindMode = other.KeybindMode;
        LanguageOverride = other.LanguageOverride;
        CanMove = other.CanMove;
        CanResize = other.CanResize;
        ShowTitleBar = other.ShowTitleBar;
        ShowPopOutTitleBar = other.ShowPopOutTitleBar;
        DatabaseBattleMessages = other.DatabaseBattleMessages;
        LoadPreviousSession = other.LoadPreviousSession;
        FilterIncludePreviousSessions = other.FilterIncludePreviousSessions;
        SortAutoTranslate = other.SortAutoTranslate;
        CollapseDuplicateMessages = other.CollapseDuplicateMessages;
        CollapseKeepUniqueLinks = other.CollapseKeepUniqueLinks;
        SymbolPickerEnabled = other.SymbolPickerEnabled;
        PlaySounds = other.PlaySounds;
        CustomSoundVolume = other.CustomSoundVolume;
        NotifyFailedTell = other.NotifyFailedTell;
        NotifyPluginDisclosure = other.NotifyPluginDisclosure;
        KeepInputFocus = other.KeepInputFocus;
        MaxLinesToRender = other.MaxLinesToRender;
        Use24HourClock = other.Use24HourClock;
        ShowEmotes = other.ShowEmotes;
        // Deep-copy so settings window edits don't leak into live config before Save.
        BlockedEmotes = new HashSet<string>(other.BlockedEmotes);
        FontsEnabled = other.FontsEnabled;
        ItalicEnabled = other.ItalicEnabled;
        ExtraGlyphRanges = other.ExtraGlyphRanges;
        FontSizeV2 = other.FontSizeV2;
        GlobalFontV2 = other.GlobalFontV2;
        JapaneseFontV2 = other.JapaneseFontV2;
        ItalicFontV2 = other.ItalicFontV2;
        SymbolsFontSizeV2 = other.SymbolsFontSizeV2;
        TooltipOffset = other.TooltipOffset;
        ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value);
        ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton;

        // Keep live temp tabs alive across UpdateFrom — a settings save must
        // not destroy open tell conversations. Pinned TempTabs are persistent
        // and come through `other` like regular tabs; unpinned TempTabs are
        // session-only and held from the local state. For persistent tabs
        // (incl. pinned), capture live runtime state by Identifier and restore
        // it onto the freshly cloned tabs — CurrentChannel is critical because
        // the user may have switched channel in-game between settings-open
        // and settings-save, and we'd otherwise overwrite that with the
        // settings-time snapshot.
        var liveUnpinnedTempTabs = Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
        var livePersistentSession = Tabs.Where(t => !TabLifecycleHelpers.IsInUnpinnedPool(t))
            .ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread, t.CurrentChannel));

        Tabs = other
            .Tabs.Where(t => !t.IsTempTab || t.IsPinned)
            .Select(t =>
            {
                var clone = t.Clone();
                if (livePersistentSession.TryGetValue(clone.Identifier, out var live))
                {
                    clone.Messages = live.Messages;
                    clone.LastSendUnread = live.LastSendUnread;
                    clone.CurrentChannel = live.CurrentChannel;
                }
                return clone;
            })
            .ToList();
        Tabs.AddRange(liveUnpinnedTempTabs);

        ChatTabForward = other.ChatTabForward;
        ChatTabBackward = other.ChatTabBackward;

        PrivacyFilterEnabled = other.PrivacyFilterEnabled;
        PrivacyPersistChannels = [.. other.PrivacyPersistChannels];
        PrivacyPersistUnknownChannels = other.PrivacyPersistUnknownChannels;

        RetentionEnabled = other.RetentionEnabled;
        RetentionDefaultDays = other.RetentionDefaultDays;
        RetentionPerChannelDays = other.RetentionPerChannelDays.ToDictionary(
            p => p.Key,
            p => p.Value
        );
        RetentionLastRunAt = other.RetentionLastRunAt;

        FirstRunCompleted = other.FirstRunCompleted;
        WizardLastShownVersion = other.WizardLastShownVersion;
        UseHellionFont = other.UseHellionFont;
        ShowHonorificTitleInHeader = other.ShowHonorificTitleInHeader;
        ShowHonorificGlow = other.ShowHonorificGlow;

        // v1.1.0 theme engine fields
        Theme = other.Theme;
        WindowOpacity = other.WindowOpacity;
        WindowOpacityInactive = other.WindowOpacityInactive;
        ReduceMotion = other.ReduceMotion;
        UseCompactDensity = other.UseCompactDensity;

        EnableAutoTellTabs = other.EnableAutoTellTabs;
        AutoTellTabsLimit = other.AutoTellTabsLimit;
        AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay;
        AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload;
        SidebarWidth = other.SidebarWidth;
        AutoTellTabsShowGreetedToggle = other.AutoTellTabsShowGreetedToggle;

        SeenPopOutInputHint = other.SeenPopOutInputHint;
        PopOutInputEnabled = other.PopOutInputEnabled;
        SeenPopOutHeaderHint = other.SeenPopOutHeaderHint;
        AutoTellTabsOpenAsPopout = other.AutoTellTabsOpenAsPopout;

        WorldSuffixMode = other.WorldSuffixMode;
        NameFormMode = other.NameFormMode;
    }
}

[Serializable]
public enum UnreadMode
{
    All,
    Unseen,
    None,
}

public static class UnreadModeExt
{
    internal static string Name(this UnreadMode mode) =>
        mode switch
        {
            UnreadMode.All => Language.UnreadMode_All,
            UnreadMode.Unseen => Language.UnreadMode_Unseen,
            UnreadMode.None => Language.UnreadMode_None,
            _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
        };

    internal static string? Tooltip(this UnreadMode mode) =>
        mode switch
        {
            UnreadMode.All => Language.UnreadMode_All_Tooltip,
            UnreadMode.Unseen => Language.UnreadMode_Unseen_Tooltip,
            UnreadMode.None => Language.UnreadMode_None_Tooltip,
            _ => null,
        };
}

[Serializable]
public class Tab
{
    public string Name = Language.Tab_DefaultName;

    // Optional FontAwesome glyph name; null falls back to TabIconMapping default.
    public string? Icon = null;

    [Obsolete("Removed in favor of SelectedChannels")]
    public Dictionary<ChatType, ChatSource> ChatCodes = new();

    public Dictionary<ChatType, (ChatSource, ChatSource)> SelectedChannels = new();
    public bool ExtraChatAll;
    public HashSet<Guid> ExtraChatChannels = [];

    public UnreadMode UnreadMode = UnreadMode.Unseen;
    public bool UnhideOnActivity;
    public bool DisplayTimestamp = true;
    public InputChannel? Channel;
    public bool PopOut;
    public bool IndependentOpacity;
    public float Opacity = 100f;
    public bool InputDisabled;

    public bool CanMove = true;
    public bool CanResize = true;

    public bool IndependentHide;
    public bool HideDuringCutscenes = true;
    public bool HideWhenNotLoggedIn = true;
    public bool HideWhenUiHidden = true;
    public bool HideInLoadingScreens;
    public bool HideInBattle;
    public bool HideWhenInactive;

    public bool IsTempTab;

    // Pinned TempTabs survive plugin reload and logout — tester feedback from
    // Jin (v1.4.7). Pinned tabs live in their own pool (MaxPinnedTempTabs)
    // separate from the AutoTellTabsLimit bucket.
    public bool IsPinned;
    public bool AllSenderMessages;
    public TellTarget TellTarget = TellTarget.Empty();

    // Per-tab notification sound for messages arriving in an inactive tab.
    public bool EnableNotificationSound;
    public uint NotificationSoundId = 1;

    [NonSerialized]
    public uint Unread;

    [NonSerialized]
    public uint LastSendUnread;

    [NonSerialized]
    public long LastActivity;

    [NonSerialized]
    public MessageList Messages = new();

    [NonSerialized]
    public UsedChannel CurrentChannel = new();

    [NonSerialized]
    public Guid Identifier = Guid.NewGuid();

    // Session-only greeted flag for club-greeter workflows.
    [NonSerialized]
    public bool IsGreeted;

    // Separate validation keys per cache so TellTarget changes don't
    // cause GetTint and GetIcon to strand each other with stale entries.
    [NonSerialized]
    internal string? _cachedTintTellName;

    [NonSerialized]
    internal uint _cachedTintTellWorld;

    [NonSerialized]
    internal uint _cachedTellTint;

    [NonSerialized]
    internal string? _cachedIconTellName;

    [NonSerialized]
    internal uint _cachedIconTellWorld;

    [NonSerialized]
    internal string? _cachedTellIcon;

    // PM-3 hover-lerp state. Default 0f means "not hovered". Sidebar
    // path animates per tab; card-mode-border path is tab-aggregate
    // (any card-row hover ramps the alpha for all cards in this tab).
    // Lerp speed lives in the render loop, not here, so the same field
    // serves both sites at the same animation curve.
    [NonSerialized]
    internal float _hoverAlpha;

    [NonSerialized]
    internal float _cardHoverAlpha;

    public bool Matches(Message message)
    {
        if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels))
            return false;

        // Temp tabs are bound to a single conversation partner — other tells
        // matching the channel filter must not land here.
        if (IsTempTab && TellTarget?.IsSet() == true)
            return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);

        return true;
    }

    public void AddMessage(Message message, bool unread = true)
    {
        Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
        if (!unread)
            return;

        Unread += 1;
        if (
            message.Matches(
                Plugin.Config.InactivityHideChannelsV2,
                Plugin.Config.InactivityHideExtraChatAll,
                Plugin.Config.InactivityHideExtraChatChannels
            )
        )
            LastActivity = Environment.TickCount64;
    }

    public void Clear() => Messages.Clear();

    public Tab Clone()
    {
        return new Tab
        {
            Name = Name,
            SelectedChannels = SelectedChannels.ToDictionary(pair => pair.Key, pair => pair.Value),
            ExtraChatAll = ExtraChatAll,
            ExtraChatChannels = ExtraChatChannels.ToHashSet(),
            UnreadMode = UnreadMode,
            UnhideOnActivity = UnhideOnActivity,
            Unread = Unread,
            LastActivity = LastActivity,
            DisplayTimestamp = DisplayTimestamp,
            Channel = Channel,
            PopOut = PopOut,
            IndependentOpacity = IndependentOpacity,
            Opacity = Opacity,
            Identifier = Identifier,
            InputDisabled = InputDisabled,
            CurrentChannel = CurrentChannel.Clone(),
            CanMove = CanMove,
            CanResize = CanResize,
            IndependentHide = IndependentHide,
            HideDuringCutscenes = HideDuringCutscenes,
            HideWhenNotLoggedIn = HideWhenNotLoggedIn,
            HideWhenUiHidden = HideWhenUiHidden,
            HideInLoadingScreens = HideInLoadingScreens,
            HideInBattle = HideInBattle,
            HideWhenInactive = HideWhenInactive,
            IsTempTab = IsTempTab,
            IsPinned = IsPinned,
            AllSenderMessages = AllSenderMessages,
            TellTarget = TellTarget.Clone(),
            EnableNotificationSound = EnableNotificationSound,
            NotificationSoundId = NotificationSoundId,
            IsGreeted = IsGreeted,
        };
    }

    /// Ordered message list with duplicate ID tracking, sorting and mutex protection.
    public class MessageList
    {
        private readonly SemaphoreSlim LockSlim = new(1, 1);

        private readonly List<Message> Messages;
        private readonly HashSet<Guid> TrackedMessageIds;

        public MessageList()
        {
            Messages = [];
            TrackedMessageIds = [];
        }

        public MessageList(int initialCapacity)
        {
            Messages = new List<Message>(initialCapacity);
            TrackedMessageIds = new HashSet<Guid>(initialCapacity);
        }

        public void AddPrune(Message message, int max)
        {
            LockSlim.Wait(-1);
            try
            {
                AddLocked(message);
                PruneMaxLocked(max);
            }
            finally
            {
                LockSlim.Release();
            }
        }

        public void AddSortPrune(IEnumerable<Message> messages, int max)
        {
            LockSlim.Wait(-1);
            try
            {
                foreach (var message in messages)
                    AddLocked(message);

                SortLocked();
                PruneMaxLocked(max);
            }
            finally
            {
                LockSlim.Release();
            }
        }

        private void AddLocked(Message message)
        {
            if (TrackedMessageIds.Contains(message.Id))
                return;

            Messages.Add(message);
            TrackedMessageIds.Add(message.Id);
        }

        public void Clear()
        {
            LockSlim.Wait(-1);
            try
            {
                Messages.Clear();
                TrackedMessageIds.Clear();
            }
            finally
            {
                LockSlim.Release();
            }
        }

        private void SortLocked()
        {
            Messages.Sort((a, b) => a.Date.CompareTo(b.Date));
        }

        private void PruneMaxLocked(int max)
        {
            while (Messages.Count > max)
            {
                TrackedMessageIds.Remove(Messages[0].Id);
                Messages.RemoveAt(0);
            }
        }

        /// Current message count. Lock-per-read is acceptable for 1×/sec status bar polling.
        public int Count
        {
            get
            {
                LockSlim.Wait(-1);
                try
                {
                    return Messages.Count;
                }
                finally
                {
                    LockSlim.Release();
                }
            }
        }

        /// Returns an array copy of the message list for usage outside of main thread.
        public async Task<Message[]> GetCopy(int millisecondsTimeout = -1)
        {
            await LockSlim.WaitAsync(millisecondsTimeout);
            try
            {
                return Messages.ToArray();
            }
            finally
            {
                LockSlim.Release();
            }
        }

        /// Returns a read-only list while holding a reader lock. Use with a using statement.
        public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1)
        {
            LockSlim.Wait(millisecondsTimeout);
            return new RLockedMessageList(LockSlim, Messages);
        }

        public class RLockedMessageList(SemaphoreSlim lockSlim, List<Message> messages)
            : IReadOnlyList<Message>,
                IDisposable
        {
            public IEnumerator<Message> GetEnumerator()
            {
                return messages.GetEnumerator();
            }

            IEnumerator IEnumerable.GetEnumerator()
            {
                return GetEnumerator();
            }

            public int Count => messages.Count;

            public Message this[int index] => messages[index];

            public void Dispose()
            {
                lockSlim.Release();
            }
        }
    }
}

public class UsedChannel
{
    public InputChannel Channel = InputChannel.Invalid;
    public List<Chunk> Name = [];
    public TellTarget? TellTarget;

    public bool UseTempChannel;
    public InputChannel TempChannel = InputChannel.Invalid;
    public TellTarget? TempTellTarget;

    public void ResetTempChannel()
    {
        UseTempChannel = false;
        TempTellTarget = null;
        TempChannel = InputChannel.Invalid;
    }

    public void SetChannel(InputChannel channel)
    {
        Channel = channel;
    }

    // ---------------------------------------------------------------
    // Cherry-picked from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12)
    // - Deep-clone the UsedChannel so Tab.Clone() no longer shares
    //   channel state (incl. TellTarget) with its origin Tab. Previously
    //   a reference copy: PopOut and Temp tabs mutated each other.
    // - Name is intentionally a reference copy (matches upstream); it
    //   gets reassigned on every channel switch anyway.
    // TEST-MIRROR: ../../Hellion Build test/_Helpers/UsedChannelCloneTests.cs
    // ---------------------------------------------------------------
    public UsedChannel Clone()
    {
        return new UsedChannel
        {
            Channel = Channel,
            Name = Name,
            TellTarget = TellTarget?.Clone(),

            UseTempChannel = UseTempChannel,
            TempChannel = TempChannel,
            TempTellTarget = TempTellTarget?.Clone(),
        };
    }
}

[Serializable]
public enum PreviewPosition
{
    None,
    Inside,
    Top,
    Bottom,
    Tooltip,
}

public static class PreviewPositionExt
{
    public static string Name(this PreviewPosition position) =>
        position switch
        {
            PreviewPosition.None => Language.Options_Preview_None,
            PreviewPosition.Inside => Language.Options_Preview_Inside,
            PreviewPosition.Top => Language.Options_Preview_Top,
            PreviewPosition.Bottom => Language.Options_Preview_Bottom,
            PreviewPosition.Tooltip => Language.Options_Preview_Tooltip,
            _ => throw new ArgumentOutOfRangeException(nameof(position), position, null),
        };
}

[Serializable]
public enum CommandHelpSide
{
    None,
    Left,
    Right,
}

public static class CommandHelpSideExt
{
    public static string Name(this CommandHelpSide side) =>
        side switch
        {
            CommandHelpSide.None => Language.CommandHelpSide_None,
            CommandHelpSide.Left => Language.CommandHelpSide_Left,
            CommandHelpSide.Right => Language.CommandHelpSide_Right,
            _ => throw new ArgumentOutOfRangeException(nameof(side), side, null),
        };
}

[Serializable]
public enum KeybindMode
{
    Flexible,
    Strict,
}

public static class KeybindModeExt
{
    public static string Name(this KeybindMode mode) =>
        mode switch
        {
            KeybindMode.Flexible => Language.KeybindMode_Flexible_Name,
            KeybindMode.Strict => Language.KeybindMode_Strict_Name,
            _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
        };

    public static string? Tooltip(this KeybindMode mode) =>
        mode switch
        {
            KeybindMode.Flexible => Language.KeybindMode_Flexible_Tooltip,
            KeybindMode.Strict => Language.KeybindMode_Strict_Tooltip,
            _ => null,
        };
}

[Serializable]
public enum LanguageOverride
{
    None,
    ChineseSimplified,
    ChineseTraditional,
    Dutch,
    English,
    French,
    German,
    Greek,
    Japanese,
    PortugueseBrazil,
    Romanian,
    Russian,
    Spanish,
    Swedish,

    // v1.5.3: Crowdin-heritage activated and Forge-maintained additions.
    // Append-only to preserve serialized integer values of existing user configs.
    Italian,
    Korean,
    Norwegian,
    Catalan,
    Czech,
    Danish,
    Finnish,
    Hungarian,
    Polish,
    PortuguesePortugal,
    Turkish,
    Ukrainian,
}

public static class LanguageOverrideExt
{
    public static string Name(this LanguageOverride mode) =>
        mode switch
        {
            LanguageOverride.None => Language.LanguageOverride_None,
            LanguageOverride.ChineseSimplified => "简体中文",
            LanguageOverride.ChineseTraditional => "繁體中文",
            LanguageOverride.Dutch => "Nederlands",
            LanguageOverride.English => "English",
            LanguageOverride.French => "Français",
            LanguageOverride.German => "Deutsch",
            LanguageOverride.Greek => "Ελληνικά",
            LanguageOverride.Italian => "Italiano",
            LanguageOverride.Japanese => "日本語",
            LanguageOverride.Korean => "한국어",
            LanguageOverride.Norwegian => "Norsk bokmål",
            LanguageOverride.PortugueseBrazil => "Português do Brasil",
            LanguageOverride.Romanian => "Română",
            LanguageOverride.Russian => "Русский",
            LanguageOverride.Spanish => "Español",
            LanguageOverride.Swedish => "Svenska",
            LanguageOverride.Catalan => "Català",
            LanguageOverride.Czech => "Čeština",
            LanguageOverride.Danish => "Dansk",
            LanguageOverride.Finnish => "Suomi",
            LanguageOverride.Hungarian => "Magyar",
            LanguageOverride.Polish => "Polski",
            LanguageOverride.PortuguesePortugal => "Português (Portugal)",
            LanguageOverride.Turkish => "Türkçe",
            LanguageOverride.Ukrainian => "Українська",
            _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
        };

    public static string Code(this LanguageOverride mode) =>
        mode switch
        {
            LanguageOverride.None => "",
            LanguageOverride.ChineseSimplified => "zh-hans",
            LanguageOverride.ChineseTraditional => "zh-hant",
            LanguageOverride.Dutch => "nl",
            LanguageOverride.English => "en",
            LanguageOverride.French => "fr",
            LanguageOverride.German => "de",
            LanguageOverride.Greek => "el",
            LanguageOverride.Italian => "it",
            LanguageOverride.Japanese => "ja",
            LanguageOverride.Korean => "ko",
            LanguageOverride.Norwegian => "nb",
            LanguageOverride.PortugueseBrazil => "pt-br",
            LanguageOverride.Romanian => "ro",
            LanguageOverride.Russian => "ru",
            LanguageOverride.Spanish => "es",
            LanguageOverride.Swedish => "sv",
            LanguageOverride.Catalan => "ca",
            LanguageOverride.Czech => "cs",
            LanguageOverride.Danish => "da",
            LanguageOverride.Finnish => "fi",
            LanguageOverride.Hungarian => "hu",
            LanguageOverride.Polish => "pl",
            LanguageOverride.PortuguesePortugal => "pt-pt",
            LanguageOverride.Turkish => "tr",
            LanguageOverride.Ukrainian => "uk",
            _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
        };

    // Maps a language to the ExtraGlyphRanges flag required for full UI
    // rendering in that locale. The settings save path ORs this into
    // Mutable.ExtraGlyphRanges so users do not need to know which range
    // to tick manually. Returns 0 for locales fully covered by the default
    // ImGui glyph range (Latin-1) or by the separate Japanese font handle.
    public static ExtraGlyphRanges RequiredGlyphRanges(this LanguageOverride mode) =>
        mode switch
        {
            LanguageOverride.Korean => ExtraGlyphRanges.Korean,
            LanguageOverride.ChineseSimplified => ExtraGlyphRanges.ChineseSimplifiedCommon,
            LanguageOverride.ChineseTraditional => ExtraGlyphRanges.ChineseFull,
            LanguageOverride.Ukrainian => ExtraGlyphRanges.Cyrillic,
            LanguageOverride.Greek => ExtraGlyphRanges.Greek,
            LanguageOverride.Czech
            or LanguageOverride.Polish
            or LanguageOverride.Romanian
            or LanguageOverride.Hungarian
            or LanguageOverride.Turkish => ExtraGlyphRanges.LatinExtended,
            _ => 0,
        };
}

[Serializable]
[Flags]
public enum ExtraGlyphRanges
{
    ChineseFull = 1 << 0,
    ChineseSimplifiedCommon = 1 << 1,
    Cyrillic = 1 << 2,
    Japanese = 1 << 3,
    Korean = 1 << 4,
    Thai = 1 << 5,
    Vietnamese = 1 << 6,

    // v1.5.3: Custom ranges for languages with Latin Extended-A glyphs (Czech,
    // Polish, Romanian, Turkish, Hungarian) and Greek polytonic accents.
    LatinExtended = 1 << 7,
    Greek = 1 << 8,
}

public static class ExtraGlyphRangesExt
{
    // Custom (start, end) inclusive pair lists for ranges that ImGui does
    // not ship a built-in helper for. SetUpRanges() feeds these into
    // ImFontGlyphRangesBuilder.AddChar via the `chars` parameter of
    // BuildRange so we avoid the lifetime/pinning question that the native
    // GetGlyphRanges*-pointer pathway papers over.
    internal static readonly ushort[] LatinExtendedPairs = { 0x0100, 0x024F };
    internal static readonly ushort[] GreekPairs = { 0x0370, 0x03FF, 0x1F00, 0x1FFF };

    public static string Name(this ExtraGlyphRanges ranges) =>
        ranges switch
        {
            ExtraGlyphRanges.ChineseFull => Language.ExtraGlyphRanges_ChineseFull_Name,
            ExtraGlyphRanges.ChineseSimplifiedCommon =>
                Language.ExtraGlyphRanges_ChineseSimplifiedCommon_Name,
            ExtraGlyphRanges.Cyrillic => Language.ExtraGlyphRanges_Cyrillic_Name,
            ExtraGlyphRanges.Japanese => Language.ExtraGlyphRanges_Japanese_Name,
            ExtraGlyphRanges.Korean => Language.ExtraGlyphRanges_Korean_Name,
            ExtraGlyphRanges.Thai => Language.ExtraGlyphRanges_Thai_Name,
            ExtraGlyphRanges.Vietnamese => Language.ExtraGlyphRanges_Vietnamese_Name,
            ExtraGlyphRanges.LatinExtended => Language.ExtraGlyphRanges_LatinExtended_Name,
            ExtraGlyphRanges.Greek => Language.ExtraGlyphRanges_Greek_Name,
            _ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
        };

    public static unsafe nint Range(this ExtraGlyphRanges ranges) =>
        ranges switch
        {
            ExtraGlyphRanges.ChineseFull => (nint)ImGui.GetIO().Fonts.GetGlyphRangesChineseFull(),
            ExtraGlyphRanges.ChineseSimplifiedCommon => (nint)
                ImGui.GetIO().Fonts.GetGlyphRangesChineseSimplifiedCommon(),
            ExtraGlyphRanges.Cyrillic => (nint)ImGui.GetIO().Fonts.GetGlyphRangesCyrillic(),
            ExtraGlyphRanges.Japanese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesJapanese(),
            ExtraGlyphRanges.Korean => (nint)ImGui.GetIO().Fonts.GetGlyphRangesKorean(),
            ExtraGlyphRanges.Thai => (nint)ImGui.GetIO().Fonts.GetGlyphRangesThai(),
            ExtraGlyphRanges.Vietnamese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesVietnamese(),
            // LatinExtended and Greek are applied via builder.AddChar in
            // FontManager.SetUpRanges, not through a native pointer range.
            ExtraGlyphRanges.LatinExtended => 0,
            ExtraGlyphRanges.Greek => 0,
            _ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
        };
}
