using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
#if UNITY_6000_3_OR_NEWER
using UnityEditor.Toolbars;
#endif

namespace Jovian.Logger {
    public class CustomConsole : EditorWindow {
        private static List<LogEntry> logs = new List<LogEntry>();
        private static readonly ConcurrentQueue<LogEntry> pendingLogs = new ConcurrentQueue<LogEntry>();
        private static int lastLogCount;
        private Vector2 scrollPosition;
        private LogCategory selectedLogCategory = (LogCategory)(1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192);
        private JovianLogType selectedJovianLogType = JovianLogType.Spam;
        private string searchQuery = "";
        private LogSourceFilter selectedLogSource = LogSourceFilter.Both;
        private bool showSpam = true;
        private bool showInfo = true;
        private bool showWarning = true;
        private bool showError = true;
        private bool showAssert = true;
        private bool showException = true;
        private static bool errorPause;
        private static bool persistLogs = true;
        private bool collapse;
        private bool autoScroll = true;
        private bool showTimestamps;
        private bool showFrameCount;
        private bool showColumns;
        private bool useRegexSearch;
        private Regex searchRegex;
        private GUIStyle smallButtonStyle;
        private bool remoteLogging;
        private string remoteClientName = "";
        private bool newestFirst = true;
        private static HashSet<string> ignoredLogKeys = new HashSet<string>();
        private GUIStyle pinnedBoxStyle;

        // Watch mode
        private static readonly Dictionary<string, WatchEntry> watchEntries = new Dictionary<string, WatchEntry>();
        private static readonly List<string> watchKeyOrder = new List<string>(); // insertion order
        private bool showWatchPanel = true;

        private enum SortColumn { None, Frame, Timestamp, Type, Source, File, Class, Category, Message }
        private SortColumn sortColumn = SortColumn.None;
        private bool sortAscending = true;

        private enum LogSourceFilter { Both, Custom, Unity, Remote }
        private static LoggerSettings settings;
        private static CustomConsole window;

        // ── Performance: cached filtering ──
        private bool filterDirty = true;
        private int lastFilteredLogCount;
        private readonly List<(LogEntry log, int collapseCount)> cachedPinned = new();
        private readonly List<(LogEntry log, int collapseCount)> cachedUnpinned = new();

        // ── Performance: incremental type counts ──
        private int[] typeCounts = new int[6];
        private int lastCountedLogCount;

        // ── Performance: GUIStyle cache ──
        private GUIStyle[] logStyleCache;
        private bool logStyleCacheDirty = true;

        // ── Performance: virtualized scroll ──
        private const float RowHeight = 30f;
        private bool hasBlinkingIndicators;

        // Cached during Layout to ensure the same row count is drawn during Repaint,
        // preventing "Invalid GUILayout state" errors from mismatched Begin/End calls.
        private int _virtPinnedCount;
        private int _virtUnpinnedCount;
        private int _virtFirst;
        private int _virtLast;

        // Subtle tint drawn over the default box background for logs created after the last compile
        private static readonly Color newLogTint = new Color(1f, 1f, 1f, 0.06f);
        private float viewportHeight;

        // ── Detail pane (stack trace) ──
        private LogEntry selectedLog;
        private float detailPaneHeight = 150f;
        private Vector2 detailPaneScroll;
        private bool isDraggingDetailPane;
        private GUIStyle stackTraceLinkStyle;
        private GUIStyle stackTraceDimStyle;
        private GUIStyle stackTraceNormalStyle;
        private const float MinDetailPaneHeight = 60f;
        private const float MaxDetailPaneHeight = 500f;
        private const float DetailPaneDragHandleHeight = 6f;

        // ── Older-log collapse (Persist mode) ──
        private bool olderLogsCollapsed = true;
        private int _filteredOlderCount; // old logs hidden behind the collapse bar

        private static string PrefsPrefix => "CustomConsole_" + Application.dataPath.GetHashCode().ToString("X") + "_";

        [InitializeOnLoadMethod]
        private static void Register() {
            persistLogs = EditorPrefs.GetBool(PrefsPrefix + "persistLogs", true);
            LoggerUtility.FormattedLogCallback += HandleCustomLog;
            LoggerUtility.WatchCallback += HandleWatch;
            LoggerUtility.UnwatchCallback += HandleUnwatch;
            Application.logMessageReceived += HandleUnityLog;
            AssemblyReloadEvents.beforeAssemblyReload += SaveSessionLogs;
            RestoreSessionLogs();
            CaptureExistingConsoleLogs();
            if (!persistLogs) {
                // Persist off: keep compile errors/warnings (they're relevant to the
                // current compilation) but clear everything else for a clean slate.
                // On the 2nd reload of the same recompile (reloadCount > 0), also keep
                // logs marked isNew — they came from the 1st reload and are part of the
                // same recompile, so they should appear as current logs, not be cached.
                logs.RemoveAll(log => !log.isCompileError && !log.isCompileWarning && !log.isNew);
            }
            LoadIgnoreList();
            EditorApplication.delayCall += () => {
                settings = LoggerUtility.LoadCustomLoggerSettings();
                if(window != null) {
                    window.logStyleCacheDirty = true;
                }

                // Re-capture after all InitializeOnLoadMethod and DidReloadScripts
                // callbacks have finished, catching any logs that fired before Register().
                CaptureExistingConsoleLogs();

                // Reset reload counter after the final domain reload completes.
                // This ensures the next recompile starts fresh at count 0.
                reloadCount = 0;
                SessionState.SetInt(SessionStateReloadKey, 0);
            };
        }

        private static void HandleCustomLog((JovianLogType logType, LogCategory logCategory, string message) log) {
            // Stack trace comes later via HandleUnityLog when Debug.unityLogger.Log fires
            var entry = new LogEntry(log.logType, log.logCategory, log.message, "", isCustomLog: true);
            if(!LoggerUtility.IsMainThread) {
                pendingLogs.Enqueue(entry);
                return;
            }

            logs.Add(entry);
            if (errorPause && EditorApplication.isPlaying && log.logType is JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert) {
                EditorApplication.isPaused = true;
            }
            window?.Repaint();
        }

        private static void HandleUnityLog(string condition, string stackTrace, UnityEngine.LogType type) {
            // Custom-formatted messages: patch the stack trace onto the existing entry
            if (IsCustomFormattedMessage(condition)) {
                bool patched = false;
                if (!string.IsNullOrEmpty(stackTrace)) {
                    for (int i = logs.Count - 1; i >= 0; i--) {
                        if (logs[i].isCustomLog && string.IsNullOrEmpty(logs[i].stackTrace)) {
                            logs[i].stackTrace = stackTrace;
                            var info = ExtractCallerInfo(stackTrace);
                            logs[i].sourceFile = info.file;
                            logs[i].className = info.className;
                            patched = true;
                            break;
                        }
                    }
                }
                // If HandleCustomLog didn't capture this (e.g. console opened after the log fired),
                // create a new entry from the Unity message so it's not silently dropped.
                if (!patched) {
                    var cleanMsg = StripRichTextTags(condition);
                    var fType = MapUnityLogType(type);
                    var entr = new LogEntry(fType, LogCategory.General, cleanMsg, stackTrace ?? "", isCustomLog: true);
                    var callerInfo = ExtractCallerInfo(stackTrace ?? "");
                    entr.sourceFile = callerInfo.file;
                    entr.className = callerInfo.className;
                    logs.Add(entr);
                    window?.Repaint();
                }
                return;
            }

            var CustomType = MapUnityLogType(type);
            var cleanCondition = StripRichTextTags(condition);
            var message = cleanCondition;
            if (type is UnityEngine.LogType.Error or UnityEngine.LogType.Assert or UnityEngine.LogType.Exception && !string.IsNullOrEmpty(stackTrace)) {
                message = $"{cleanCondition}\n{stackTrace}";
            }

            var entry = new LogEntry(CustomType, LogCategory.General, message, stackTrace ?? "", isCustomLog: false);
            if (type == UnityEngine.LogType.Error && CompileErrorRegex.IsMatch(condition)) {
                entry.isCompileError = true;
            } else if (type == UnityEngine.LogType.Warning && CompileWarningRegex.IsMatch(condition)) {
                entry.isCompileWarning = true;
            }
            logs.Add(entry);
            if (errorPause && EditorApplication.isPlaying && type is UnityEngine.LogType.Error or UnityEngine.LogType.Assert or UnityEngine.LogType.Exception) {
                EditorApplication.isPaused = true;
            }
            window?.Repaint();
        }

        private static void HandleWatch((string key, string value, LogCategory category) watch) {
            if (watchEntries.TryGetValue(watch.key, out var existing)) {
                existing.value = watch.value;
                existing.timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
                existing.frameCount = Time.frameCount;
                existing.updateCount++;
            } else {
                var entry = new WatchEntry {
                    key = watch.key,
                    value = watch.value,
                    logCategory = watch.category,
                    timestamp = DateTime.Now.ToString("HH:mm:ss.fff"),
                    frameCount = Time.frameCount,
                    updateCount = 1,
                };
                watchEntries[watch.key] = entry;
                watchKeyOrder.Add(watch.key);
            }
            window?.Repaint();
        }

        private static void HandleUnwatch(string key) {
            if (watchEntries.Remove(key)) {
                watchKeyOrder.Remove(key);
                window?.Repaint();
            }
        }

        // ── Retroactive log capture via internal LogEntries API ──
        // Other [InitializeOnLoadMethod] callbacks may have logged messages before
        // our handlers were registered.  Read Unity's internal console buffer via
        // reflection and add any entries we missed.
        private static Type s_LogEntriesType;
        private static Type s_LogEntryType;
        private static MethodInfo s_StartGettingEntries;
        private static MethodInfo s_EndGettingEntries;
        private static MethodInfo s_GetEntryInternal;
        private static FieldInfo  f_leMessage;
        private static FieldInfo  f_leMode;
        private static FieldInfo  f_leCallstackStart;
        private static bool       s_reflectionReady;

        private static void InitLogEntriesReflection() {
            if (s_reflectionReady) return;
            s_reflectionReady = true;
            var asm = Assembly.GetAssembly(typeof(EditorApplication));
            s_LogEntriesType     = asm.GetType("UnityEditor.LogEntries");
            s_LogEntryType       = asm.GetType("UnityEditor.LogEntry");
            if (s_LogEntriesType == null || s_LogEntryType == null) return;
            s_StartGettingEntries = s_LogEntriesType.GetMethod("StartGettingEntries", BindingFlags.Static | BindingFlags.Public);
            s_EndGettingEntries   = s_LogEntriesType.GetMethod("EndGettingEntries",   BindingFlags.Static | BindingFlags.Public);
            s_GetEntryInternal    = s_LogEntriesType.GetMethod("GetEntryInternal",    BindingFlags.Static | BindingFlags.Public);
            f_leMessage           = s_LogEntryType.GetField("message",                 BindingFlags.Instance | BindingFlags.Public);
            f_leMode              = s_LogEntryType.GetField("mode",                    BindingFlags.Instance | BindingFlags.Public);
            f_leCallstackStart    = s_LogEntryType.GetField("callstackTextStartUTF16", BindingFlags.Instance | BindingFlags.Public);
        }

        // LogMessageFlags bitmask categories
        private const int LMF_Error   = (1 << 0) | (1 << 1) | (1 << 4) | (1 << 6) | (1 << 8) | (1 << 11) | (1 << 17) | (1 << 21);
        private const int LMF_Warning = (1 << 7) | (1 << 9) | (1 << 12);

        private static void CaptureExistingConsoleLogs() {
            InitLogEntriesReflection();
            if (s_StartGettingEntries == null || s_GetEntryInternal == null || s_EndGettingEntries == null) return;

            // Build a map of message first-lines we already have so we can
            // skip true duplicates but mark restored (old) entries as "new"
            // when they reappear in Unity's console (i.e. they fired again this session).
            var existing = new Dictionary<string, LogEntry>();
            for (int i = 0; i < logs.Count; i++) {
                string msg = logs[i].message;
                int nl = msg.IndexOf('\n');
                string key = nl >= 0 ? msg.Substring(0, nl) : msg;
                existing.TryAdd(key, logs[i]);
            }

            int count = (int)s_StartGettingEntries.Invoke(null, null);
            try {
                for (int i = 0; i < count; i++) {
                    object entry = Activator.CreateInstance(s_LogEntryType);
                    bool ok = (bool)s_GetEntryInternal.Invoke(null, new object[] { i, entry });
                    if (!ok) continue;

                    string fullMessage = (string)f_leMessage.GetValue(entry);
                    int    mode        = (int)f_leMode.GetValue(entry);
                    int    stackStart  = f_leCallstackStart != null ? (int)f_leCallstackStart.GetValue(entry) : 0;

                    string rawLogText = stackStart > 0 ? fullMessage.Substring(0, stackStart).TrimEnd() : fullMessage;
                    string stackTrace = stackStart > 0 ? fullMessage.Substring(stackStart)              : "";

                    // Strip rich text tags (e.g. <color=#hex>...</color>) for clean display
                    string logText = StripRichTextTags(rawLogText);

                    // Skip if we already have this entry, but mark restored entries
                    // as "new" since they fired again in this session.
                    string firstLine = logText;
                    int nl = firstLine.IndexOf('\n');
                    if (nl >= 0) firstLine = firstLine.Substring(0, nl);
                    if (existing.TryGetValue(firstLine, out var existingEntry)) {
                        if (existingEntry != null && !existingEntry.isNew) {
                            existingEntry.isNew = true;
                            existingEntry.timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
                            existingEntry.frameCount = LoggerUtility.FrameCount;
                        }
                        continue;
                    }
                    existing.Add(firstLine, null); // null — no entry to promote later

                    // Determine log type from mode flags
                    JovianLogType fType;
                    if ((mode & LMF_Error) != 0)        fType = JovianLogType.Error;
                    else if ((mode & LMF_Warning) != 0)  fType = JovianLogType.Warning;
                    else                                  fType = JovianLogType.Info;

                    // Check if this is a Custom-formatted message (use raw text — detection looks for <color=> tags)
                    bool isCustom = IsCustomFormattedMessage(rawLogText);

                    // For Custom-formatted messages, parse the actual type from the prefix
                    // (the mode flags only distinguish Error/Warning/Log, losing Spam/Assert/Exception)
                    if (isCustom) {
                        if (logText.StartsWith("SPAM -> "))           fType = JovianLogType.Spam;
                        else if (logText.StartsWith("INFO -> "))      fType = JovianLogType.Info;
                        else if (logText.StartsWith("WARNING -> "))   fType = JovianLogType.Warning;
                        else if (logText.StartsWith("ERROR -> "))     fType = JovianLogType.Error;
                        else if (logText.StartsWith("ASSERT -> "))    fType = JovianLogType.Assert;
                        else if (logText.StartsWith("EXCEPTION -> ")) fType = JovianLogType.Exception;
                    }

                    var logEntry = new LogEntry(fType, LogCategory.General, logText, stackTrace, isCustomLog: isCustom);
                    if ((mode & (1 << 11)) != 0) logEntry.isCompileError = true;     // kScriptCompileError
                    if ((mode & (1 << 12)) != 0) logEntry.isCompileWarning = true;   // kScriptCompileWarning

                    var info = ExtractCallerInfo(stackTrace);
                    logEntry.sourceFile = info.file;
                    logEntry.className = info.className;

                    logs.Add(logEntry);
                }
            }
            catch (Exception e) {
                Debug.LogWarning($"[CustomConsole] Failed to capture existing console logs: {e.Message}");
            }
            finally {
                s_EndGettingEntries.Invoke(null, null);
            }
        }

        private static readonly string[] CustomPrefixes = {
            "INFO -> ", "ERROR -> ", "WARNING -> ",
            "EXCEPTION -> ", "ASSERT -> ", "SPAM -> "
        };

        private static bool IsCustomFormattedMessage(string condition) {
            foreach (var prefix in CustomPrefixes) {
                if (condition.StartsWith(prefix, System.StringComparison.Ordinal)) {
                    return true;
                }
            }

            if (condition.StartsWith("<color=#", System.StringComparison.Ordinal) && condition.Length > 22) {
                var afterTag = condition.AsSpan(15);
                foreach (var prefix in CustomPrefixes) {
                    if (afterTag.StartsWith(prefix.AsSpan(), System.StringComparison.Ordinal)) {
                        return true;
                    }
                }
            }

            return false;
        }

        private static JovianLogType MapUnityLogType(UnityEngine.LogType type) {
            return type switch {
                UnityEngine.LogType.Error => JovianLogType.Error,
                UnityEngine.LogType.Assert => JovianLogType.Assert,
                UnityEngine.LogType.Warning => JovianLogType.Warning,
                UnityEngine.LogType.Exception => JovianLogType.Exception,
                _ => JovianLogType.Info,
            };
        }

        private static readonly string[] LogTypeFilterNames = { "Everything", "Exception", "Assert", "Error", "Warning", "Info" };
        private static readonly JovianLogType[] LogTypeFilterValues = {
            JovianLogType.Spam, JovianLogType.Exception, JovianLogType.Assert,
            JovianLogType.Error, JovianLogType.Warning, JovianLogType.Info
        };

        private static readonly LogCategory AllCategories = (LogCategory)(1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192);

        private void DrawLogCategoryFilter() {
            string label = selectedLogCategory == AllCategories ? "Everything" : selectedLogCategory == 0 ? "Nothing" : selectedLogCategory.ToString();
            if (GUILayout.Button(label, EditorStyles.toolbarDropDown, GUILayout.Width(150))) {
                var menu = new GenericMenu();
                menu.AddItem(new GUIContent("Everything"), selectedLogCategory == AllCategories, () => { selectedLogCategory = AllCategories; filterDirty = true; Repaint(); });
                menu.AddItem(new GUIContent("Nothing"), selectedLogCategory == 0, () => { selectedLogCategory = 0; filterDirty = true; Repaint(); });
                menu.AddSeparator("");
                foreach (LogCategory cat in Enum.GetValues(typeof(LogCategory))) {
                    if (cat == LogCategory.None) continue;
                    LogCategory c = cat;
                    bool on = selectedLogCategory.HasFlag(c);
                    menu.AddItem(new GUIContent(c.ToString()), on, () => {
                        if (on) selectedLogCategory &= ~c;
                        else selectedLogCategory |= c;
                        filterDirty = true;
                        Repaint();
                    });
                }
                menu.ShowAsContext();
            }
        }

        private static JovianLogType DrawLogTypeFilter(JovianLogType current) {
            int index = Array.IndexOf(LogTypeFilterValues, current);
            if (index < 0) index = 0;
            int newIndex = EditorGUILayout.Popup(index, LogTypeFilterNames, EditorStyles.toolbarPopup, GUILayout.Width(100));
            return LogTypeFilterValues[newIndex];
        }

        [MenuItem("Fidelit&y/&Utility/Custom Logger/Custom Console", false, 2)]
        public static void ShowWindow() {
            window = GetWindow<CustomConsole>("Custom Console");
            window.Show();
        }

        private void RefreshWindow() {
            // Reset all internal state and caches without closing the window
            SavePrefs();

            // Clear cached styles so they're rebuilt
            smallButtonStyle = null;
            pinnedBoxStyle = null;
            logStyleCache = null;
            logStyleCacheDirty = true;
            stackTraceLinkStyle = null;
            stackTraceDimStyle = null;
            stackTraceNormalStyle = null;

            // Reset filter/view state
            filterDirty = true;
            lastFilteredLogCount = 0;
            lastCountedLogCount = 0;
            Array.Clear(typeCounts, 0, typeCounts.Length);
            cachedPinned.Clear();
            cachedUnpinned.Clear();
            selectedLog = null;
            scrollPosition = Vector2.zero;
            detailPaneScroll = Vector2.zero;

            // Reload settings and prefs
            settings = LoggerUtility.LoadCustomLoggerSettings();
            LoadPrefs();

            Repaint();
        }

        private void OnEnable() {
            window = this;
            LoadPrefs();
            RemoteLogReceiver.OnRemoteLog += HandleRemoteLog;
            RemoteLogReceiver.OnRemoteWatch += HandleRemoteWatch;
            RemoteLogReceiver.OnRemoteUnwatch += HandleRemoteUnwatch;
            RemoteLogReceiver.OnClientConnected += HandleRemoteClientConnected;
            RemoteLogReceiver.OnClientDisconnected += HandleRemoteClientDisconnected;
        }

        private void OnDisable() {
            SavePrefs();
            RemoteLogReceiver.OnRemoteLog -= HandleRemoteLog;
            RemoteLogReceiver.OnRemoteWatch -= HandleRemoteWatch;
            RemoteLogReceiver.OnRemoteUnwatch -= HandleRemoteUnwatch;
            RemoteLogReceiver.OnClientConnected -= HandleRemoteClientConnected;
            RemoteLogReceiver.OnClientDisconnected -= HandleRemoteClientDisconnected;
            if (remoteLogging) {
                RemoteLogReceiver.Stop();
                remoteLogging = false;
            }
            if (window == this) {
                window = null;
            }
        }

        private void HandleRemoteLog(RemoteLogReceiver.RemoteLogEntry remote) {
            var entry = new LogEntry(remote.type, remote.logCategory, remote.message, remote.stackTrace, remote.isCustomLog);
            entry.timestamp = remote.timestamp;
            entry.frameCount = remote.frameCount;
            entry.isRemote = true;
            logs.Add(entry);
            if (errorPause && EditorApplication.isPlaying && remote.type is JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert) {
                EditorApplication.isPaused = true;
            }
            Repaint();
        }

        private void HandleRemoteWatch(RemoteLogReceiver.RemoteWatchEntry remote) {
            if (watchEntries.TryGetValue(remote.key, out var existing)) {
                existing.value = remote.value;
                existing.timestamp = remote.timestamp;
                existing.frameCount = remote.frameCount;
                existing.updateCount++;
            } else {
                var entry = new WatchEntry {
                    key = remote.key,
                    value = remote.value,
                    logCategory = remote.logCategory,
                    timestamp = remote.timestamp,
                    frameCount = remote.frameCount,
                    updateCount = 1,
                    isRemote = true,
                };
                watchEntries[remote.key] = entry;
                watchKeyOrder.Add(remote.key);
            }
            Repaint();
        }

        private void HandleRemoteUnwatch(string key) {
            if (watchEntries.Remove(key)) {
                watchKeyOrder.Remove(key);
                Repaint();
            }
        }

        private void HandleRemoteClientConnected(string clientName) {
            remoteClientName = clientName;
            Repaint();
        }

        private void HandleRemoteClientDisconnected() {
            remoteClientName = "";
            Repaint();
        }

        private void SavePrefs() {
            string p = PrefsPrefix;
            EditorPrefs.SetBool(p + "showSpam", showSpam);
            EditorPrefs.SetBool(p + "showInfo", showInfo);
            EditorPrefs.SetBool(p + "showWarning", showWarning);
            EditorPrefs.SetBool(p + "showError", showError);
            EditorPrefs.SetBool(p + "showAssert", showAssert);
            EditorPrefs.SetBool(p + "showException", showException);
            EditorPrefs.SetBool(p + "errorPause", errorPause);
            EditorPrefs.SetBool(p + "collapse", collapse);
            EditorPrefs.SetBool(p + "autoScroll", autoScroll);
            EditorPrefs.SetBool(p + "showTimestamps", showTimestamps);
            EditorPrefs.SetBool(p + "showFrameCount", showFrameCount);
            EditorPrefs.SetBool(p + "showColumns", showColumns);
            EditorPrefs.SetBool(p + "useRegexSearch", useRegexSearch);
            EditorPrefs.SetInt(p + "selectedLogSource", (int)selectedLogSource);
            EditorPrefs.SetInt(p + "selectedLogType", (int)selectedJovianLogType);
            EditorPrefs.SetInt(p + "selectedLogCategory", (int)selectedLogCategory);
            EditorPrefs.SetInt(p + "sortColumn", (int)sortColumn);
            EditorPrefs.SetBool(p + "sortAscending", sortAscending);
            EditorPrefs.SetString(p + "searchQuery", searchQuery);
            EditorPrefs.SetBool(p + "newestFirst", newestFirst);
            EditorPrefs.SetBool(p + "showWatchPanel", showWatchPanel);
            EditorPrefs.SetBool(p + "persistLogs", persistLogs);
            EditorPrefs.SetFloat(p + "detailPaneHeight", detailPaneHeight);
            EditorPrefs.SetBool(p + "olderLogsCollapsed", olderLogsCollapsed);
        }

        private void LoadPrefs() {
            string p = PrefsPrefix;
            if (!EditorPrefs.HasKey(p + "showSpam")) return; // No saved prefs yet
            showSpam = EditorPrefs.GetBool(p + "showSpam", true);
            showInfo = EditorPrefs.GetBool(p + "showInfo", true);
            showWarning = EditorPrefs.GetBool(p + "showWarning", true);
            showError = EditorPrefs.GetBool(p + "showError", true);
            showAssert = EditorPrefs.GetBool(p + "showAssert", true);
            showException = EditorPrefs.GetBool(p + "showException", true);
            errorPause = EditorPrefs.GetBool(p + "errorPause", false);
            collapse = EditorPrefs.GetBool(p + "collapse", false);
            autoScroll = EditorPrefs.GetBool(p + "autoScroll", true);
            showTimestamps = EditorPrefs.GetBool(p + "showTimestamps", false);
            showFrameCount = EditorPrefs.GetBool(p + "showFrameCount", false);
            showColumns = EditorPrefs.GetBool(p + "showColumns", false);
            useRegexSearch = EditorPrefs.GetBool(p + "useRegexSearch", false);
            selectedLogSource = (LogSourceFilter)EditorPrefs.GetInt(p + "selectedLogSource", 0);
            selectedJovianLogType = (JovianLogType)EditorPrefs.GetInt(p + "selectedLogType", (int)JovianLogType.Spam);
            selectedLogCategory = (LogCategory)EditorPrefs.GetInt(p + "selectedLogCategory", (int)AllCategories);
            sortColumn = (SortColumn)EditorPrefs.GetInt(p + "sortColumn", 0);
            sortAscending = EditorPrefs.GetBool(p + "sortAscending", true);
            searchQuery = EditorPrefs.GetString(p + "searchQuery", "");
            newestFirst = EditorPrefs.GetBool(p + "newestFirst", true);
            showWatchPanel = EditorPrefs.GetBool(p + "showWatchPanel", true);
            persistLogs = EditorPrefs.GetBool(p + "persistLogs", true);
            detailPaneHeight = EditorPrefs.GetFloat(p + "detailPaneHeight", 150f);
            olderLogsCollapsed = EditorPrefs.GetBool(p + "olderLogsCollapsed", true);
            if (useRegexSearch) CompileSearchRegex();
        }

        private void OnGUI() {
            while(pendingLogs.TryDequeue(out LogEntry pending)) {
                logs.Add(pending);
            }

            hasBlinkingIndicators = false;
            if (smallButtonStyle == null) {
                smallButtonStyle = new GUIStyle(EditorStyles.miniButton) { fontSize = 9 };
            }
            if (pinnedBoxStyle == null) {
                pinnedBoxStyle = new GUIStyle("box");
                pinnedBoxStyle.border = new RectOffset(2, 2, 2, 2);
                pinnedBoxStyle.normal.background = MakePinnedBackground();
            }
            // Toolbar row 1: Actions & log type icons
            GUILayout.BeginHorizontal(EditorStyles.toolbar);
            { bool prevPersist = persistLogs;
              string persistLabel = persistLogs ? "Persist \u25CF" : "Persist";
              persistLogs = GUILayout.Toggle(persistLogs, new GUIContent(persistLabel, "Toggle log persistence across assembly reloads"), EditorStyles.toolbarButton);
              if(persistLogs != prevPersist) {
                  bool confirmed = EditorUtility.DisplayDialog(
                      "Toggle Log Persistence",
                      persistLogs
                          ? "Enable log persistence?\n\nLogs will be kept across assembly reloads."
                          : "Disable log persistence?\n\nLogs will be saved to the log cache and cleared on each assembly reload.",
                      "Confirm",
                      "Cancel");
                  if(!confirmed) {
                      persistLogs = prevPersist;
                  }
              } }
            if(GUILayout.Button("Clear", EditorStyles.toolbarButton)) {
                SaveLogCache();
                logs.Clear();
                Array.Clear(typeCounts, 0, typeCounts.Length);
                lastCountedLogCount = 0;
                filterDirty = true;
            }
            errorPause = GUILayout.Toggle(errorPause, "Error Pause", EditorStyles.toolbarButton);
            { bool prev = collapse;
              collapse = GUILayout.Toggle(collapse, "Collapse", EditorStyles.toolbarButton);
              if(collapse != prev) filterDirty = true; }
            autoScroll = GUILayout.Toggle(autoScroll, "Auto Scroll", EditorStyles.toolbarButton);
            GUILayout.Space(6);
            GUILayout.Label("", EditorStyles.toolbarButton, GUILayout.Width(1));
            GUILayout.Space(6);
            showFrameCount = GUILayout.Toggle(showFrameCount, "Frame #", EditorStyles.toolbarButton);
            showTimestamps = GUILayout.Toggle(showTimestamps, "Timestamps", EditorStyles.toolbarButton);
            { bool prev = showColumns;
              showColumns = GUILayout.Toggle(showColumns, new GUIContent("Columns", "Show column-based display with sortable headers for Type, Source, File, Class, and Category"), EditorStyles.toolbarButton);
              if(showColumns != prev) filterDirty = true; }
            GUILayout.Space(6);
            GUILayout.Label("", EditorStyles.toolbarButton, GUILayout.Width(1));
            GUILayout.Space(6);
            bool prevRemote = remoteLogging;
            string remoteLabel = remoteLogging && RemoteLogReceiver.HasClient
                ? "Remote ●"
                : "Remote";
            string remoteTooltip = remoteLogging
                ? (RemoteLogReceiver.HasClient
                    ? $"Connected: {remoteClientName}\nListening on port {RemoteLogReceiver.Port}"
                    : $"Listening on port {RemoteLogReceiver.Port}... waiting for device")
                : "Enable remote logging to receive logs from device builds.\nAdd CustomRemoteLogSender to your scene.";
            remoteLogging = GUILayout.Toggle(remoteLogging, new GUIContent(remoteLabel, remoteTooltip), EditorStyles.toolbarButton);
            if(remoteLogging != prevRemote) {
                if(remoteLogging) {
                    RemoteLogReceiver.Start();
                }
                else {
                    RemoteLogReceiver.Stop();
                    remoteClientName = "";
                }
            }

            GUILayout.FlexibleSpace();

            // Incremental type counts (O(1) when no new logs)
            UpdateTypeCounts();
            int spamCount = typeCounts[(int)JovianLogType.Spam];
            int infoCount = typeCounts[(int)JovianLogType.Info];
            int warnCount = typeCounts[(int)JovianLogType.Warning];
            int errCount = typeCounts[(int)JovianLogType.Error];
            int assertCount = typeCounts[(int)JovianLogType.Assert];
            int exceptionCount = typeCounts[(int)JovianLogType.Exception];

            { bool prev = showSpam;
              showSpam = GUILayout.Toggle(showSpam, new GUIContent($" {spamCount}", EditorGUIUtility.IconContent("TreeEditor.Trash").image, "Spam"), EditorStyles.toolbarButton);
              if(showSpam != prev) filterDirty = true; }
            { bool prev = showInfo;
              showInfo = GUILayout.Toggle(showInfo, new GUIContent($" {infoCount}", EditorGUIUtility.IconContent("console.infoicon.sml").image, "Info"), EditorStyles.toolbarButton);
              if(showInfo != prev) filterDirty = true; }
            { bool prev = showWarning;
              showWarning = GUILayout.Toggle(showWarning, new GUIContent($" {warnCount}", EditorGUIUtility.IconContent("console.warnicon.sml").image, "Warning"), EditorStyles.toolbarButton);
              if(showWarning != prev) filterDirty = true; }
            { bool prev = showError;
              showError = GUILayout.Toggle(showError, new GUIContent($" {errCount}", EditorGUIUtility.IconContent("console.erroricon.sml").image, "Error"), EditorStyles.toolbarButton);
              if(showError != prev) filterDirty = true; }
            { bool prev = showAssert;
              showAssert = GUILayout.Toggle(showAssert, new GUIContent($" {assertCount}", EditorGUIUtility.IconContent("d_DebuggerEnabled").image, "Assert"), EditorStyles.toolbarButton);
              if(showAssert != prev) filterDirty = true; }
            { bool prev = showException;
              showException = GUILayout.Toggle(showException, new GUIContent($" {exceptionCount}", EditorGUIUtility.IconContent("CollabError").image, "Exception"), EditorStyles.toolbarButton);
              if(showException != prev) filterDirty = true; }

            GUILayout.EndHorizontal();

            // Toolbar row 2: Filters & search
            GUILayout.BeginHorizontal(EditorStyles.toolbar);
            if(GUILayout.Button(new GUIContent(newestFirst ? "Newest First" : "Oldest First", "Toggle log order between newest first and oldest first"), EditorStyles.toolbarButton)) {
                newestFirst = !newestFirst;
                filterDirty = true;
            }
            GUILayout.Label("Log Source:", EditorStyles.miniLabel, GUILayout.Width(68));
            { LogSourceFilter prev = selectedLogSource;
              selectedLogSource = (LogSourceFilter)EditorGUILayout.EnumPopup(selectedLogSource, EditorStyles.toolbarPopup, GUILayout.Width(80));
              if(selectedLogSource != prev) filterDirty = true; }
            GUILayout.Space(10);
            GUILayout.Label("Filters:", EditorStyles.miniLabel, GUILayout.Width(42));
            { LogCategory prevCat = selectedLogCategory;
              DrawLogCategoryFilter();
              if(selectedLogCategory != prevCat) filterDirty = true; }
            { JovianLogType prevType = selectedJovianLogType;
              selectedJovianLogType = DrawLogTypeFilter(selectedJovianLogType);
              if(selectedJovianLogType != prevType) filterDirty = true; }
            GUILayout.Space(10);
            string prevQuery = searchQuery;
            bool prevRegex = useRegexSearch;
            searchQuery = GUILayout.TextField(searchQuery, EditorStyles.toolbarSearchField, GUILayout.Width(200));
            useRegexSearch = GUILayout.Toggle(useRegexSearch, new GUIContent(".*", "Regex search\nExample: error|warning\nExample: Player\\d+\nExample: ^Init"), EditorStyles.toolbarButton, GUILayout.Width(25));
            if(searchQuery != prevQuery || useRegexSearch != prevRegex) {
                CompileSearchRegex();
                filterDirty = true;
            }
            GUILayout.FlexibleSpace();
            if (GUILayout.Button("Refresh", EditorStyles.toolbarButton)) {
                RefreshWindow();
            }
            if (GUILayout.Button("Export", EditorStyles.toolbarDropDown)) {
                var menu = new GenericMenu();
                menu.AddItem(new GUIContent("Export Logs"), false, ExportLogs);
                menu.AddItem(new GUIContent("Copy for AI"), false, ExportLogsForAI);
                menu.ShowAsContext();
            }
            if (GUILayout.Button("Log Cache", EditorStyles.toolbarDropDown)) {
                var menu = new GenericMenu();
                var cachedFiles = GetCachedLogFiles();
                if (cachedFiles.Length == 0) {
                    menu.AddDisabledItem(new GUIContent("No cached logs"));
                } else {
                    foreach (var file in cachedFiles) {
                        string label = file.CreationTime.ToString("MMM dd, HH:mm:ss");
                        string path = file.FullName;
                        menu.AddItem(new GUIContent(label), false, () => OpenCachedLog(path));
                    }
                    menu.AddSeparator("");
                    menu.AddItem(new GUIContent("Clear All Cached Logs"), false, ClearLogCache);
                }
                menu.ShowAsContext();
            }
            GUILayout.EndHorizontal();

            // Column header row (only in column mode)
            if (showColumns) {
                GUILayout.BeginHorizontal(EditorStyles.toolbar);
                if (collapse) DrawColumnHeader("#", SortColumn.None, 30);
                if (showFrameCount) DrawColumnHeader("Frame", SortColumn.Frame, 50);
                if (showTimestamps) DrawColumnHeader("Time", SortColumn.Timestamp, 75);
                DrawColumnHeader("Type", SortColumn.Type, 65);
                DrawColumnHeader("Src", SortColumn.Source, 35);
                DrawColumnHeader("File", SortColumn.File, 120);
                DrawColumnHeader("Class", SortColumn.Class, 120);
                DrawColumnHeader("Category", SortColumn.Category, 90);
                DrawColumnHeader("Message", SortColumn.Message, 0);
                GUILayout.Label("", EditorStyles.toolbarButton, GUILayout.Width(88));
                GUILayout.EndHorizontal();
            }

            // Watch panel
            if (watchEntries.Count > 0) {
                DrawWatchPanel();
            }

            // Scrollable Log List
            if(autoScroll && logs.Count != lastLogCount) {
                scrollPosition = newestFirst ? Vector2.zero : new Vector2(0, float.MaxValue);
                lastLogCount = logs.Count;
            }

            RebuildFilteredListIfDirty();

            // Cache visible range during Layout so that Repaint draws the exact same
            // number of rows. A mismatch causes "Invalid GUILayout state" errors because
            // each DrawLogRow contains BeginHorizontal/EndHorizontal pairs.
            const float OlderBarHeight = 24f;
            if (Event.current.type == EventType.Layout) {
                _virtPinnedCount = cachedPinned.Count;
                _virtUnpinnedCount = cachedUnpinned.Count;

                float pinnedHeight = _virtPinnedCount * RowHeight + (_virtPinnedCount > 0 ? 5f : 0f);
                float maxContentY = Mathf.Max(0, _virtUnpinnedCount * RowHeight - viewportHeight);
                float scrollY = Mathf.Clamp(scrollPosition.y - pinnedHeight, 0, maxContentY);
                _virtFirst = Mathf.Max(0, Mathf.FloorToInt(scrollY / RowHeight));
                int visibleCount = Mathf.CeilToInt(viewportHeight / RowHeight) + 2;
                _virtLast = Mathf.Min(_virtUnpinnedCount - 1, _virtFirst + visibleCount);
            }

            scrollPosition = GUILayout.BeginScrollView(scrollPosition);

            // Render pinned first (top) — always fully rendered (usually very few)
            if(_virtPinnedCount > 0) {
                for(int i = 0; i < _virtPinnedCount && i < cachedPinned.Count; i++) {
                    DrawLogRow(cachedPinned[i].log, cachedPinned[i].collapseCount, isPinned: true);
                }
                // Visual separator between pinned and unpinned
                GUILayout.Space(2);
                Rect sepRect = GUILayoutUtility.GetRect(0, 1, GUILayout.ExpandWidth(true));
                EditorGUI.DrawRect(sepRect, new Color(1f, 0.8f, 0f, 0.5f));
                GUILayout.Space(2);
            }

            // Unpinned: virtualized — only draw visible rows
            int firstVisible = _virtFirst;
            int lastVisible = _virtLast;
            int unpinnedCount = _virtUnpinnedCount;

            if(firstVisible > 0) {
                GUILayout.Space(firstVisible * RowHeight);
            }

            for(int i = firstVisible; i <= lastVisible && i < cachedUnpinned.Count; i++) {
                DrawLogRow(cachedUnpinned[i].log, cachedUnpinned[i].collapseCount, isPinned: false);
            }

            int afterCount = unpinnedCount - lastVisible - 1;
            if(afterCount > 0) {
                GUILayout.Space(afterCount * RowHeight);
            }

            GUILayout.EndScrollView();

            // Capture viewport height on Repaint for virtualization calculations
            if(Event.current.type == EventType.Repaint) {
                float captured = GUILayoutUtility.GetLastRect().height;
                if(captured > 1f) {
                    viewportHeight = captured;
                }
            }

            // Sticky older-logs bar — outside scroll view, always visible
            // Shows when there are hidden older logs OR when expanded and older logs exist
            if (persistLogs && _filteredOlderCount > 0) {
                DrawOlderLogsBar(OlderBarHeight);
            } else if (persistLogs && !olderLogsCollapsed && HasAnyOlderLogs()) {
                DrawOlderLogsBar(OlderBarHeight);
            }

            // Detail pane: stack trace for selected log
            DrawDetailPane();

            // Jump to latest button
            GUILayout.BeginHorizontal();
            GUILayout.FlexibleSpace();
            string jumpLabel = newestFirst ? "↑ Jump to Latest" : "↓ Jump to Latest";
            if(GUILayout.Button(jumpLabel, EditorStyles.miniButton, GUILayout.Width(110))) {
                scrollPosition = newestFirst ? Vector2.zero : new Vector2(0, float.MaxValue);
            }
            GUILayout.FlexibleSpace();
            GUILayout.EndHorizontal();

            // Keep repainting while blinking compile-error indicators are visible
            if (hasBlinkingIndicators) {
                Repaint();
            }
        }

        private void DrawLogRow(LogEntry log, int collapseCount, bool isPinned) {
            GUIStyle logStyle = GetLogStyle(log);
            bool isLong = IsMultiLine(log.message, MaxCollapsedLines);
            bool isExpanded = log.expanded && isLong;
            GUIStyle rowStyle = isPinned ? pinnedBoxStyle : (GUIStyle)"box";
            Rect rowRect = isExpanded
                ? EditorGUILayout.BeginHorizontal(rowStyle)
                : EditorGUILayout.BeginHorizontal(rowStyle, GUILayout.Height(RowHeight));

            // Draw a subtle lighter background for logs created after the last assembly reload
            if (log.isNew && Event.current.type == EventType.Repaint) {
                EditorGUI.DrawRect(rowRect, newLogTint);
            }

            // Highlight selected row
            if (selectedLog == log && Event.current.type == EventType.Repaint) {
                EditorGUI.DrawRect(rowRect, new Color(0.17f, 0.36f, 0.53f, 0.4f));
            }

            // Tooltip: "Double-click to open source"
            if (!string.IsNullOrEmpty(log.stackTrace) || log.isCompileError || log.isCompileWarning) {
                GUI.Label(rowRect, new GUIContent("", "Double-click to open source"));
            }

            // Double-click: open source file in IDE
            if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && Event.current.clickCount == 2 && rowRect.Contains(Event.current.mousePosition)) {
                Event.current.Use();
                string sourceRef = (log.isCompileError || log.isCompileWarning) && !HasSourceLocation(log.stackTrace) ? log.message : log.stackTrace;
                if (HasSourceLocation(sourceRef)) {
                    OpenSourceLocation(sourceRef);
                }
            }
            // Single-click: select log to show stack trace in detail pane
            else if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && Event.current.clickCount == 1 && rowRect.Contains(Event.current.mousePosition)) {
                selectedLog = log;
                detailPaneScroll = Vector2.zero;
                Repaint();
            }

            // Intercept right-click before controls can steal it (TextArea has its own context menu)
            if (Event.current.type == EventType.MouseDown && Event.current.button == 1 && rowRect.Contains(Event.current.mousePosition)) {
                Event.current.Use();
                ShowLogContextMenu(log);
            }

            if (collapse) {
                GUILayout.Label(collapseCount > 1 ? collapseCount.ToString() : "", EditorStyles.miniLabel, GUILayout.Width(30));
            }
            if (showFrameCount) {
                GUILayout.Label(log.frameCount.ToString(), EditorStyles.miniLabel, GUILayout.Width(50));
            }
            if (showTimestamps) {
                GUILayout.Label(log.timestamp, EditorStyles.miniLabel, GUILayout.Width(75));
            }
            if (showColumns) {
                GUILayout.Label(log.type.ToString(), logStyle, GUILayout.Width(65));
                string srcLabel = log.isRemote ? (log.isCustomLog ? "RF" : "RU") : (log.isCustomLog ? "F" : "U");
                GUILayout.Label(srcLabel, EditorStyles.miniLabel, GUILayout.Width(35));
                GUILayout.Label(log.sourceFile ?? "", EditorStyles.miniLabel, GUILayout.Width(120));
                GUILayout.Label(log.className ?? "", EditorStyles.miniLabel, GUILayout.Width(120));
                GUILayout.Label(log.isCustomLog ? log.logCategory.ToString() : "", EditorStyles.miniLabel, GUILayout.Width(90));
            }
            string displayText = isLong && !log.expanded
                ? TruncateToLines(log.message, MaxCollapsedLines) + " ..."
                : log.message;
            EditorGUILayout.TextArea(displayText, logStyle);
            if (log.isNew && log.isCompileError) {
                float t = Mathf.PingPong((float)EditorApplication.timeSinceStartup * 3f, 1f);
                Color blinkColor = Color.Lerp(Color.yellow, Color.red, t);
                Rect lineRect = GUILayoutUtility.GetRect(5, RowHeight, GUILayout.Width(5));
                EditorGUI.DrawRect(lineRect, blinkColor);
                hasBlinkingIndicators = true;
            } else if (log.isNew && log.isCompileWarning) {
                Rect lineRect = GUILayoutUtility.GetRect(2.5f, RowHeight, GUILayout.Width(2.5f));
                EditorGUI.DrawRect(lineRect, Color.yellow);
            }
            if (isLong) {
                if (GUILayout.Button(log.expanded ? "▲" : "▼", smallButtonStyle, GUILayout.Width(18))) {
                    log.expanded = !log.expanded;
                }
            }
            if (GUILayout.Button("Copy", smallButtonStyle, GUILayout.Width(32))) {
                EditorGUIUtility.systemCopyBuffer = log.message;
            }
            { string sourceRef = (log.isCompileError || log.isCompileWarning) && !HasSourceLocation(log.stackTrace) ? log.message : log.stackTrace;
              using (new EditorGUI.DisabledScope(!HasSourceLocation(sourceRef))) {
                  if (GUILayout.Button("Show", smallButtonStyle, GUILayout.Width(34))) {
                      OpenSourceLocation(sourceRef);
                  }
              } }

            EditorGUILayout.EndHorizontal();
        }

        private void EnsureDetailPaneStyles() {
            if (stackTraceLinkStyle == null) {
                stackTraceLinkStyle = new GUIStyle(EditorStyles.label) {
                    richText = false,
                    wordWrap = false,
                    normal = { textColor = new Color(0.3f, 0.6f, 1f) },
                    hover = { textColor = new Color(0.5f, 0.75f, 1f) },
                    padding = new RectOffset(4, 4, 1, 1),
                };
            }
            if (stackTraceDimStyle == null) {
                stackTraceDimStyle = new GUIStyle(EditorStyles.label) {
                    richText = false,
                    wordWrap = false,
                    normal = { textColor = new Color(0.5f, 0.5f, 0.5f) },
                    padding = new RectOffset(4, 4, 1, 1),
                };
            }
            if (stackTraceNormalStyle == null) {
                stackTraceNormalStyle = new GUIStyle(EditorStyles.label) {
                    richText = false,
                    wordWrap = false,
                    padding = new RectOffset(4, 4, 1, 1),
                };
            }
        }

        private void DrawOlderLogsBar(float barHeight) {
            Rect barRect = GUILayoutUtility.GetRect(0, barHeight, GUILayout.ExpandWidth(true));
            if (Event.current.type == EventType.Repaint) {
                EditorGUI.DrawRect(barRect, new Color(0.18f, 0.18f, 0.18f, 1f));
                // Top/bottom border lines
                EditorGUI.DrawRect(new Rect(barRect.x, barRect.y, barRect.width, 1), new Color(0.4f, 0.4f, 0.4f, 0.5f));
                EditorGUI.DrawRect(new Rect(barRect.x, barRect.yMax - 1, barRect.width, 1), new Color(0.4f, 0.4f, 0.4f, 0.5f));
            }
            string arrow = olderLogsCollapsed ? "▶" : "▼";
            int olderCount = olderLogsCollapsed ? _filteredOlderCount : CountOlderInList();
            string label = olderLogsCollapsed
                ? $"{arrow}  {olderCount} older log{(olderCount != 1 ? "s" : "")} hidden — click to expand"
                : $"{arrow}  Collapse {olderCount} older log{(olderCount != 1 ? "s" : "")}";
            GUI.Label(barRect, label, EditorStyles.centeredGreyMiniLabel);
            // Handle click
            if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && barRect.Contains(Event.current.mousePosition)) {
                Event.current.Use();
                olderLogsCollapsed = !olderLogsCollapsed;
                // When expanding in oldest-first, scroll to bottom so
                // the newly revealed old logs (which are above) push the
                // view toward the latest entries the user was looking at.
                if (!olderLogsCollapsed && !newestFirst) {
                    scrollPosition = new Vector2(0, float.MaxValue);
                }
                SavePrefs();
                filterDirty = true;
                Repaint();
            }
            EditorGUIUtility.AddCursorRect(barRect, MouseCursor.Link);
        }

        private bool HasAnyOlderLogs() {
            for (int i = 0; i < cachedUnpinned.Count; i++) {
                if (!cachedUnpinned[i].log.isNew) return true;
            }
            return false;
        }

        private int CountOlderInList() {
            int count = 0;
            for (int i = 0; i < cachedUnpinned.Count; i++) {
                if (!cachedUnpinned[i].log.isNew) count++;
            }
            return count;
        }

        private void DrawDetailPane() {
            if (selectedLog == null) return;
            string trace = selectedLog.stackTrace;
            bool hasTrace = !string.IsNullOrEmpty(trace);

            EnsureDetailPaneStyles();

            // ── Drag handle ──
            Rect handleRect = GUILayoutUtility.GetRect(0, DetailPaneDragHandleHeight, GUILayout.ExpandWidth(true));
            EditorGUIUtility.AddCursorRect(handleRect, MouseCursor.ResizeVertical);
            if (Event.current.type == EventType.Repaint) {
                Color handleColor = isDraggingDetailPane
                    ? new Color(0.4f, 0.6f, 0.9f, 0.8f)
                    : new Color(0.5f, 0.5f, 0.5f, 0.5f);
                EditorGUI.DrawRect(handleRect, handleColor);
                float cx = handleRect.center.x;
                float cy = handleRect.center.y;
                Color dotColor = new Color(0.7f, 0.7f, 0.7f, 0.8f);
                for (int i = -2; i <= 2; i++) {
                    EditorGUI.DrawRect(new Rect(cx + i * 8 - 1, cy - 1, 3, 3), dotColor);
                }
            }

            // Handle drag
            if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && handleRect.Contains(Event.current.mousePosition)) {
                isDraggingDetailPane = true;
                Event.current.Use();
            }
            if (isDraggingDetailPane) {
                if (Event.current.type == EventType.MouseDrag) {
                    detailPaneHeight -= Event.current.delta.y;
                    detailPaneHeight = Mathf.Clamp(detailPaneHeight, MinDetailPaneHeight, MaxDetailPaneHeight);
                    Event.current.Use();
                    Repaint();
                }
                if (Event.current.type == EventType.MouseUp) {
                    isDraggingDetailPane = false;
                    Event.current.Use();
                }
            }

            // ── Detail pane content ──
            if (!hasTrace) {
                EditorGUILayout.BeginVertical("box", GUILayout.Height(detailPaneHeight));
                GUILayout.Label("No stack trace available.", EditorStyles.miniLabel);
                GUILayout.FlexibleSpace();
                EditorGUILayout.EndVertical();
                return;
            }

            // Header with copy button
            GUILayout.BeginHorizontal(EditorStyles.toolbar);
            GUILayout.Label("Stack Trace", EditorStyles.miniLabel);
            GUILayout.FlexibleSpace();
            if (GUILayout.Button("Copy", EditorStyles.toolbarButton, GUILayout.Width(40))) {
                EditorGUIUtility.systemCopyBuffer = trace;
            }
            GUILayout.EndHorizontal();

            detailPaneScroll = EditorGUILayout.BeginScrollView(detailPaneScroll, "box", GUILayout.Height(detailPaneHeight));

            string[] lines = trace.Split('\n');
            foreach (string rawLine in lines) {
                string line = rawLine.TrimEnd('\r');
                if (string.IsNullOrWhiteSpace(line)) continue;

                var match = SourceLocationRegex.Match(line);
                bool isInternal = false;
                foreach (var internalPath in InternalPaths) {
                    if (line.Contains(internalPath, StringComparison.Ordinal)) {
                        isInternal = true;
                        break;
                    }
                }

                if (match.Success) {
                    string filePath = match.Groups[1].Value;
                    bool isValidPath = !filePath.StartsWith("<") && filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase);
                    GUIStyle style = isInternal ? stackTraceDimStyle : (isValidPath ? stackTraceLinkStyle : stackTraceNormalStyle);
                    string tooltip = isValidPath ? $"Click to open {Path.GetFileName(filePath)}:{match.Groups[2].Value}" : null;
                    Rect lineRect = GUILayoutUtility.GetRect(new GUIContent(line, tooltip), style, GUILayout.ExpandWidth(true));

                    // Right-click: copy this line
                    if (Event.current.type == EventType.MouseDown && Event.current.button == 1 && lineRect.Contains(Event.current.mousePosition)) {
                        Event.current.Use();
                        var menu = new GenericMenu();
                        string capturedLine = line;
                        menu.AddItem(new GUIContent("Copy Line"), false, () => EditorGUIUtility.systemCopyBuffer = capturedLine);
                        menu.AddItem(new GUIContent("Copy Full Stack Trace"), false, () => EditorGUIUtility.systemCopyBuffer = trace);
                        menu.ShowAsContext();
                    }

                    // Left-click: open source (only for valid paths)
                    if (isValidPath) {
                        EditorGUIUtility.AddCursorRect(lineRect, MouseCursor.Link);
                        if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && lineRect.Contains(Event.current.mousePosition)) {
                            Event.current.Use();
                            int lineNum = int.Parse(match.Groups[2].Value);
                            UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(filePath, lineNum);
                        }
                    }

                    if (Event.current.type == EventType.Repaint) {
                        style.Draw(lineRect, new GUIContent(line, tooltip), lineRect.Contains(Event.current.mousePosition), false, false, false);
                    }
                } else {
                    GUIStyle style = isInternal ? stackTraceDimStyle : stackTraceNormalStyle;
                    Rect lineRect = GUILayoutUtility.GetRect(new GUIContent(line), style, GUILayout.ExpandWidth(true));

                    // Right-click: copy
                    if (Event.current.type == EventType.MouseDown && Event.current.button == 1 && lineRect.Contains(Event.current.mousePosition)) {
                        Event.current.Use();
                        var menu = new GenericMenu();
                        string capturedLine = line;
                        menu.AddItem(new GUIContent("Copy Line"), false, () => EditorGUIUtility.systemCopyBuffer = capturedLine);
                        menu.AddItem(new GUIContent("Copy Full Stack Trace"), false, () => EditorGUIUtility.systemCopyBuffer = trace);
                        menu.ShowAsContext();
                    }

                    if (Event.current.type == EventType.Repaint) {
                        style.Draw(lineRect, line, false, false, false, false);
                    }
                }
            }

            EditorGUILayout.EndScrollView();
        }

        private void DrawWatchPanel() {
            // Header bar
            GUILayout.BeginHorizontal(EditorStyles.toolbar);
            showWatchPanel = EditorGUILayout.Foldout(showWatchPanel, $"Watch ({watchEntries.Count})", true, EditorStyles.foldout);
            GUILayout.FlexibleSpace();
            if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(40))) {
                watchEntries.Clear();
                watchKeyOrder.Clear();
            }
            GUILayout.EndHorizontal();

            if (!showWatchPanel) return;

            // Watch rows
            var watchBg = EditorGUIUtility.isProSkin ? new Color(0.18f, 0.22f, 0.28f, 1f) : new Color(0.85f, 0.92f, 1f, 1f);
            foreach (var key in watchKeyOrder) {
                if (!watchEntries.TryGetValue(key, out var entry)) continue;
                var rect = EditorGUILayout.BeginHorizontal("box");
                EditorGUI.DrawRect(rect, watchBg);
                GUILayout.Label(entry.key, EditorStyles.boldLabel, GUILayout.Width(150));
                GUILayout.Label(entry.value, EditorStyles.label);
                GUILayout.FlexibleSpace();
                if (showTimestamps) {
                    GUILayout.Label(entry.timestamp, EditorStyles.miniLabel, GUILayout.Width(75));
                }
                if (showFrameCount) {
                    GUILayout.Label(entry.frameCount.ToString(), EditorStyles.miniLabel, GUILayout.Width(50));
                }
                GUILayout.Label($"×{entry.updateCount}", EditorStyles.miniLabel, GUILayout.Width(40));
                if (entry.isRemote) {
                    GUILayout.Label("R", EditorStyles.miniLabel, GUILayout.Width(14));
                }
                if (GUILayout.Button("×", smallButtonStyle, GUILayout.Width(18))) {
                    watchEntries.Remove(key);
                    watchKeyOrder.Remove(key);
                    GUILayout.EndHorizontal();
                    break; // collection modified
                }
                GUILayout.EndHorizontal();
            }

            // Separator
            GUILayout.Space(1);
            var sepRect = GUILayoutUtility.GetRect(0, 1, GUILayout.ExpandWidth(true));
            EditorGUI.DrawRect(sepRect, new Color(0.4f, 0.6f, 0.9f, 0.5f));
            GUILayout.Space(1);
        }

        private void DrawColumnHeader(string label, SortColumn column, float width) {
            string arrow = sortColumn == column ? (sortAscending ? " ▲" : " ▼") : "";
            bool clicked;
            if (width > 0)
                clicked = GUILayout.Button(label + arrow, EditorStyles.toolbarButton, GUILayout.Width(width));
            else
                clicked = GUILayout.Button(label + arrow, EditorStyles.toolbarButton);

            if(clicked && column != SortColumn.None) {
                if(sortColumn == column) {
                    sortAscending = !sortAscending;
                }
                else {
                    sortColumn = column;
                    sortAscending = true;
                }
                filterDirty = true;
            }
        }

        private static Texture2D MakePinnedBackground() {
            int w = 8, h = 8;
            var tex = new Texture2D(w, h);
            var border = new Color(1f, 0.75f, 0f, 1f); // gold
            var fill = EditorGUIUtility.isProSkin ? new Color(0.25f, 0.23f, 0.15f, 1f) : new Color(1f, 0.97f, 0.85f, 1f);
            for (int y = 0; y < h; y++) {
                for (int x = 0; x < w; x++) {
                    bool isBorder = x == 0 || x == w - 1 || y == 0 || y == h - 1;
                    tex.SetPixel(x, y, isBorder ? border : fill);
                }
            }
            tex.Apply();
            tex.hideFlags = HideFlags.HideAndDontSave;
            return tex;
        }

        private static string GetActiveTextSelection() {
            var editor = typeof(EditorGUI)
                .GetField("activeEditor", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)
                ?.GetValue(null) as TextEditor;
            if (editor != null && editor.hasSelection)
                return editor.SelectedText;
            return null;
        }

        private void CompileSearchRegex() {
            if (!useRegexSearch || string.IsNullOrEmpty(searchQuery)) {
                searchRegex = null;
                return;
            }
            try {
                searchRegex = new Regex(searchQuery, RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(50));
            } catch (ArgumentException) {
                searchRegex = null;
            }
        }

        private static string GetCollapseKey(LogEntry log) {
            // First line of the message + source flag
            string firstLine = log.message;
            int newlineIdx = firstLine.IndexOf('\n');
            if(newlineIdx >= 0) firstLine = firstLine.Substring(0, newlineIdx);
            return $"{(log.isCustomLog ? "F" : "U")}|{log.type}|{firstLine}";
        }

        // ── Performance: cached filtering + type counts ──

        private void RebuildFilteredListIfDirty() {
            if(!filterDirty && logs.Count == lastFilteredLogCount) {
                return;
            }

            cachedPinned.Clear();
            cachedUnpinned.Clear();
            _filteredOlderCount = 0;

            // Build filtered list with collapse counts and per-group metadata
            Dictionary<string, int> collapseCounts = null;
            Dictionary<string, bool> collapseHasNew = null;       // any member isNew?
            Dictionary<string, string> collapseLatestTime = null;  // latest timestamp
            Dictionary<string, int> collapseLatestFrame = null;    // latest frame count
            HashSet<string> collapseSeenOld = null;
            HashSet<string> collapseSeenNew = null;
            if(collapse) {
                collapseCounts = new Dictionary<string, int>();
                collapseHasNew = new Dictionary<string, bool>();
                collapseLatestTime = new Dictionary<string, string>();
                collapseLatestFrame = new Dictionary<string, int>();
                collapseSeenOld = new HashSet<string>();
                collapseSeenNew = new HashSet<string>();
                for(int i = 0; i < logs.Count; i++) {
                    LogEntry entry = logs[i];
                    if(!MatchesFilter(entry)) continue;
                    string key = GetCollapseKey(entry);
                    collapseCounts[key] = collapseCounts.GetValueOrDefault(key, 0) + 1;
                    if (entry.isNew) collapseHasNew[key] = true;
                    else collapseHasNew.TryAdd(key, false);
                    if (!collapseLatestTime.TryGetValue(key, out string prev) ||
                        string.Compare(entry.timestamp, prev, StringComparison.Ordinal) > 0) {
                        collapseLatestTime[key] = entry.timestamp;
                        collapseLatestFrame[key] = entry.frameCount;
                    }
                }
            }

            // First pass: count old vs new among filtered unpinned logs so we
            // know whether hiding older logs makes sense (need BOTH old and new).
            bool wantHideOlder = persistLogs && olderLogsCollapsed;
            int oldCount = 0, newCount = 0;
            if (wantHideOlder) {
                for (int i = 0; i < logs.Count; i++) {
                    LogEntry entry = logs[i];
                    if (!MatchesFilter(entry) || entry.pinned) continue;
                    if (entry.isNew) newCount++; else oldCount++;
                }
            }
            bool hideOlder = wantHideOlder && oldCount > 0 && newCount > 0;

            int start = newestFirst ? logs.Count - 1 : 0;
            int end = newestFirst ? -1 : logs.Count;
            int step = newestFirst ? -1 : 1;
            for(int i = start; i != end; i += step) {
                LogEntry entry = logs[i];
                if(!MatchesFilter(entry)) continue;

                // When hiding older logs, skip them BEFORE collapse dedup so
                // their keys don't block newer logs with the same message.
                if(hideOlder && !entry.isNew && !entry.pinned) {
                    _filteredOlderCount++;
                    continue;
                }

                int cc = 1;
                if(collapse) {
                    string key = GetCollapseKey(entry);
                    // When hiding older logs, use separate seen-sets for old vs new
                    // so that in oldest-first mode an old duplicate doesn't consume
                    // the key and prevent the new version from appearing.
                    // When expanded (or not hiding), use a single set — the group
                    // metadata (isNew, timestamp) already promotes the representative.
                    if (hideOlder) {
                        var seen = entry.isNew ? collapseSeenNew : collapseSeenOld;
                        if(!seen.Add(key)) continue;
                    } else {
                        if(!collapseSeenOld.Add(key)) continue;
                    }
                    cc = collapseCounts[key];

                    // Promote the representative entry to reflect the entire group:
                    // show as "new" if any member is new, use the latest timestamp/frame.
                    if (collapseHasNew.TryGetValue(key, out bool hasNew) && hasNew)
                        entry.isNew = true;
                    if (collapseLatestTime.TryGetValue(key, out string latestTs)) {
                        entry.timestamp = latestTs;
                        entry.frameCount = collapseLatestFrame[key];
                    }
                }

                if(entry.pinned) {
                    cachedPinned.Add((entry, cc));
                }
                else {
                    cachedUnpinned.Add((entry, cc));
                }
            }

            // When collapse is on, re-sort by latest timestamp so groups with
            // recent activity float to the correct position instead of staying
            // at the position of their first occurrence.
            if(collapse) {
                int dir = newestFirst ? -1 : 1;
                cachedUnpinned.Sort((a, b) => dir * string.Compare(a.log.timestamp, b.log.timestamp, StringComparison.Ordinal));
            }

            // Sort if a column is selected (only in column mode)
            if(showColumns && sortColumn != SortColumn.None) {
                int Compare((LogEntry log, int collapseCount) a, (LogEntry log, int collapseCount) b) {
                    int cmp = sortColumn switch {
                        SortColumn.Frame => a.log.frameCount.CompareTo(b.log.frameCount),
                        SortColumn.Timestamp => string.Compare(a.log.timestamp, b.log.timestamp, StringComparison.Ordinal),
                        SortColumn.Type => a.log.type.CompareTo(b.log.type),
                        SortColumn.Source => a.log.isCustomLog.CompareTo(b.log.isCustomLog),
                        SortColumn.File => string.Compare(a.log.sourceFile, b.log.sourceFile, StringComparison.OrdinalIgnoreCase),
                        SortColumn.Class => string.Compare(a.log.className, b.log.className, StringComparison.OrdinalIgnoreCase),
                        SortColumn.Category => a.log.logCategory.CompareTo(b.log.logCategory),
                        SortColumn.Message => string.Compare(a.log.message, b.log.message, StringComparison.OrdinalIgnoreCase),
                        _ => 0,
                    };
                    return sortAscending ? cmp : -cmp;
                }
                cachedPinned.Sort(Compare);
                cachedUnpinned.Sort(Compare);
            }

            filterDirty = false;
            lastFilteredLogCount = logs.Count;
        }

        private void UpdateTypeCounts() {
            if(logs.Count == 0 && lastCountedLogCount != 0) {
                // Logs were cleared
                Array.Clear(typeCounts, 0, typeCounts.Length);
                lastCountedLogCount = 0;
                return;
            }

            // Count only newly added entries
            for(int i = lastCountedLogCount; i < logs.Count; i++) {
                int typeIndex = (int)logs[i].type;
                if(typeIndex >= 0 && typeIndex < typeCounts.Length) {
                    typeCounts[typeIndex]++;
                }
            }
            lastCountedLogCount = logs.Count;
        }

        private void MarkFilterDirty() {
            filterDirty = true;
        }

        private bool MatchesFilter(LogEntry log) {
            // Ignore list
            if (IsIgnored(log)) return false;

            // Source filter
            if (selectedLogSource == LogSourceFilter.Custom && !log.isCustomLog) {
                return false;
            }
            if (selectedLogSource == LogSourceFilter.Unity && log.isCustomLog) {
                return false;
            }
            if (selectedLogSource == LogSourceFilter.Remote && !log.isRemote) {
                return false;
            }

            // Per-type toggle filters
            if (log.isCustomLog) {
                // Custom logs: each toggle maps directly
                switch (log.type) {
                    case JovianLogType.Spam when !showSpam:
                    case JovianLogType.Info when !showInfo:
                    case JovianLogType.Warning when !showWarning:
                    case JovianLogType.Error when !showError:
                    case JovianLogType.Assert when !showAssert:
                    case JovianLogType.Exception when !showException:
                        return false;
                }
            } else {
                // Unity logs: Info->showInfo, Warning->showWarning, Error->showError
                switch (log.type) {
                    case JovianLogType.Info or JovianLogType.Spam when !showInfo:
                    case JovianLogType.Warning when !showWarning:
                    case JovianLogType.Error when !showError:
                        return false;
                }
            }

            // Severity filter
            if (selectedJovianLogType < log.type) {
                return false;
            }

            // Category filter (only applies to Custom logs)
            if (log.isCustomLog && !selectedLogCategory.HasFlag(log.logCategory)) {
                return false;
            }

            // Search filter
            if (!string.IsNullOrEmpty(searchQuery)) {
                if (useRegexSearch && searchRegex != null) {
                    if (!searchRegex.IsMatch(log.message)) return false;
                } else {
                    if (log.message.IndexOf(searchQuery, StringComparison.OrdinalIgnoreCase) < 0) return false;
                }
            }

            return true;
        }

        private GUIStyle GetLogStyle(LogEntry log) {
            if(logStyleCache == null || logStyleCacheDirty) {
                RebuildLogStyleCache();
            }
            int index = (int)log.type * 2 + (log.isCustomLog ? 1 : 0);
            return logStyleCache[index];
        }

        private void RebuildLogStyleCache() {
            logStyleCache = new GUIStyle[12]; // 6 log types × 2 (unity/Custom)
            for(int typeIdx = 0; typeIdx < 6; typeIdx++) {
                JovianLogType jovianLogType = (JovianLogType)typeIdx;
                for(int isCustom = 0; isCustom < 2; isCustom++) {
                    GUIStyle style = new GUIStyle(EditorStyles.label) {
                        wordWrap = true,
                        alignment = TextAnchor.UpperLeft,
                        padding = new RectOffset(0, 0, 3, 0)
                    };
                    if(isCustom == 0) {
                        style.normal.textColor = GetUnityLogColor(jovianLogType);
                    }
                    else if(settings != null) {
                        style.normal.textColor = jovianLogType switch {
                            JovianLogType.Warning => settings.loggerColors.warningColor,
                            JovianLogType.Error => settings.loggerColors.errorColor,
                            JovianLogType.Exception => settings.loggerColors.exceptionColor,
                            JovianLogType.Assert => settings.loggerColors.assertColor,
                            JovianLogType.Info => settings.loggerColors.infoColor,
                            _ => settings.loggerColors.spamColor,
                        };
                    }
                    logStyleCache[typeIdx * 2 + isCustom] = style;
                }
            }
            logStyleCacheDirty = false;
        }

        private static Color GetUnityLogColor(JovianLogType type) {
            if (settings != null) {
                return type switch {
                    JovianLogType.Warning => settings.loggerColors.warningColor,
                    JovianLogType.Error => settings.loggerColors.errorColor,
                    JovianLogType.Exception => settings.loggerColors.exceptionColor,
                    JovianLogType.Assert => settings.loggerColors.assertColor,
                    _ => Color.white,
                };
            }

            return type switch {
                JovianLogType.Warning => Color.yellow,
                JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert => Color.red,
                _ => Color.white,
            };
        }

        // Matches C# compiler errors: "Assets/Path/File.cs(10,5): error CS0246: ..."
        private static readonly Regex CompileErrorRegex = new(
            @"\.cs\(\d+,\d+\): error CS\d+:", RegexOptions.Compiled);

        // Matches C# compiler warnings: "Assets/Path/File.cs(10,5): warning CS0168: ..."
        private static readonly Regex CompileWarningRegex = new(
            @"\.cs\(\d+,\d+\): warning CS\d+:", RegexOptions.Compiled);

        // Extracts file path (group 1) and line number (group 2) from compile error messages
        private static readonly Regex CompileErrorLocationRegex = new(
            @"(.+\.cs)\((\d+),\d+\)", RegexOptions.Compiled);

        // Matches "(at Assets/Path/File.cs:123)" or "(at /absolute/path/File.cs:123)"
        private static readonly Regex SourceLocationRegex = new(
            @"\(at\s+(.+?):(\d+)\)", RegexOptions.Compiled);

        // Strips Unity rich text tags: <color=#hex>, </color>, <b>, </b>, <i>, </i>, <size=N>, </size>
        private static readonly Regex RichTextTagRegex = new(
            @"</?(?:color(?:=#?[0-9a-fA-F]+)?|b|i|size(?:=\d+)?)>", RegexOptions.Compiled);

        private static string StripRichTextTags(string text) {
            if (string.IsNullOrEmpty(text) || text.IndexOf('<') < 0) return text;
            return RichTextTagRegex.Replace(text, "");
        }

        // Stack frames from the logger itself — skip these to find the actual caller
        private static readonly string[] InternalPaths = {
            "com.resolutiongames.logger/",
            "Custom.Logger.",
            "UnityEngine.Debug",
            "UnityEngine.Logger",
            "UnityEngine.DebugLogHandler",
        };

        private static bool HasSourceLocation(string stackTrace) {
            return FindCallerMatch(stackTrace).Success;
        }

        private static void OpenSourceLocation(string stackTrace) {
            var match = FindCallerMatch(stackTrace);
            if (!match.Success) return;

            string filePath = match.Groups[1].Value;
            int line = int.Parse(match.Groups[2].Value);
            UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(filePath, line);
        }

        private static Match FindCallerMatch(string stackTrace) {
            if (string.IsNullOrEmpty(stackTrace)) return Match.Empty;

            var matches = SourceLocationRegex.Matches(stackTrace);
            // Find the lines each match belongs to, skip logger-internal frames
            string[] lines = stackTrace.Split('\n');
            foreach (string line in lines) {
                var match = SourceLocationRegex.Match(line);
                if (!match.Success) continue;

                bool isInternal = false;
                foreach (var internalPath in InternalPaths) {
                    if (line.Contains(internalPath, StringComparison.Ordinal)) {
                        isInternal = true;
                        break;
                    }
                }

                if (!isInternal) {
                    return match;
                }
            }

            // Fallback: return first match if all frames are internal
            if (matches.Count > 0) return matches[0];

            // Fallback: try compile error format — "Assets/Path/File.cs(10,5): error ..."
            var compileMatch = CompileErrorLocationRegex.Match(stackTrace);
            return compileMatch.Success ? compileMatch : Match.Empty;
        }

        private const int MaxCollapsedLines = 2;

        private static string TruncateToLines(string text, int maxLines) {
            int pos = 0;
            for (int line = 0; line < maxLines; line++) {
                int next = text.IndexOf('\n', pos);
                if (next < 0) return text;
                pos = next + 1;
            }
            return text.Substring(0, pos > 0 ? pos - 1 : text.Length);
        }

        private static bool IsMultiLine(string text, int maxLines) {
            int count = 0;
            for (int i = 0; i < text.Length; i++) {
                if (text[i] == '\n') {
                    count++;
                    if (count >= maxLines) return true;
                }
            }
            return false;
        }

        // ── Session persistence across domain reloads ────────────────────

        private const string SessionStateKey = "CustomConsole_SessionLogs";
        private const string SessionStateWatchKey = "CustomConsole_SessionWatch";
        private const string SessionStateReloadKey = "CustomConsole_ReloadCount";

        // Tracks how many domain reloads have occurred during the current recompile.
        // Unity fires two domain reloads per recompile; we use this to detect the 2nd
        // reload so we can (a) keep logs from the 1st reload marked as isNew and
        // (b) avoid double-caching logs when persist is off.
        private static int reloadCount;

        [Serializable]
        private class SerializedWatch {
            public string key;
            public string value;
            public int logCategory;
            public string timestamp;
            public int frameCount;
            public int updateCount;
            public bool isRemote;
        }

        [Serializable]
        private class SerializedWatchList {
            public SerializedWatch[] watches;
        }

        [Serializable]
        private class SerializedLog {
            public string message;
            public string stackTrace;
            public string timestamp;
            public int frameCount;
            public string sourceFile;
            public string className;
            public int type;
            public int logCategory;
            public bool isCustomLog;
            public bool isRemote;
            public bool pinned;
            public bool isCompileError;
            public bool isCompileWarning;
            public bool isNew;
        }

        private static void SaveSessionLogs() {
            // Track reload count so we can detect the 2nd reload of the same recompile.
            reloadCount++;
            SessionState.SetInt(SessionStateReloadKey, reloadCount);

            // Archive to disk cache only on the first reload to avoid double-caching.
            if (!persistLogs && reloadCount <= 1) {
                SaveLogCache();
            }

            // Always save to SessionState so RestoreSessionLogs can recover logs
            // after reload — even when persist is off, compile errors/warnings are
            // kept and non-compile entries are cleared in Register().
            if (logs.Count == 0) {
                SessionState.SetString(SessionStateKey, "");
            } else {
                var serialized = new SerializedLog[logs.Count];
                for (int i = 0; i < logs.Count; i++) {
                    var log = logs[i];
                    serialized[i] = new SerializedLog {
                        message = log.message,
                        stackTrace = log.stackTrace,
                        timestamp = log.timestamp,
                        frameCount = log.frameCount,
                        sourceFile = log.sourceFile,
                        className = log.className,
                        type = (int)log.type,
                        logCategory = (int)log.logCategory,
                        isCustomLog = log.isCustomLog,
                        isRemote = log.isRemote,
                        pinned = log.pinned,
                        isCompileError = log.isCompileError,
                        isCompileWarning = log.isCompileWarning,
                        isNew = log.isNew,
                    };
                }
                SessionState.SetString(SessionStateKey, JsonUtility.ToJson(new SerializedLogList { logs = serialized }, false));
            }

            // Save watches (always — watches are real-time monitors, not historical logs)
            if (watchEntries.Count == 0) {
                SessionState.SetString(SessionStateWatchKey, "");
            } else {
                var watches = new SerializedWatch[watchKeyOrder.Count];
                for (int i = 0; i < watchKeyOrder.Count; i++) {
                    var w = watchEntries[watchKeyOrder[i]];
                    watches[i] = new SerializedWatch {
                        key = w.key,
                        value = w.value,
                        logCategory = (int)w.logCategory,
                        timestamp = w.timestamp,
                        frameCount = w.frameCount,
                        updateCount = w.updateCount,
                        isRemote = w.isRemote,
                    };
                }
                SessionState.SetString(SessionStateWatchKey, JsonUtility.ToJson(new SerializedWatchList { watches = watches }, false));
            }
        }

        private static void RestoreSessionLogs() {
            // Recover the reload counter from before this domain reload.
            reloadCount = SessionState.GetInt(SessionStateReloadKey, 0);

            // On the 2nd+ reload of the same recompile (reloadCount > 1), logs restored
            // here came from the 1st reload and should still be treated as "new".
            // On the 1st reload (reloadCount == 1), old logs must be reset to isNew = false.
            bool keepNewStatus = reloadCount > 1;

            string json = SessionState.GetString(SessionStateKey, "");
            if (!string.IsNullOrEmpty(json)) {
                try {
                    var data = JsonUtility.FromJson<SerializedLogList>(json);
                    if (data?.logs != null) {
                        logs.Clear();
                        foreach (var s in data.logs) {
                            var entry = new LogEntry(
                                (JovianLogType)s.type,
                                (LogCategory)s.logCategory,
                                s.message,
                                s.stackTrace,
                                s.isCustomLog
                            );
                            entry.timestamp = s.timestamp;
                            entry.frameCount = s.frameCount;
                            entry.sourceFile = s.sourceFile;
                            entry.className = s.className;
                            entry.isRemote = s.isRemote;
                            entry.pinned = s.pinned;
                            entry.isCompileError = s.isCompileError;
                            entry.isCompileWarning = s.isCompileWarning;
                            entry.isNew = keepNewStatus ? s.isNew : false;
                            logs.Add(entry);
                        }
                    }
                } catch { }
            }

            // Restore watches
            string watchJson = SessionState.GetString(SessionStateWatchKey, "");
            if (!string.IsNullOrEmpty(watchJson)) {
                try {
                    var data = JsonUtility.FromJson<SerializedWatchList>(watchJson);
                    if (data?.watches != null) {
                        watchEntries.Clear();
                        watchKeyOrder.Clear();
                        foreach (var s in data.watches) {
                            var entry = new WatchEntry {
                                key = s.key,
                                value = s.value,
                                logCategory = (LogCategory)s.logCategory,
                                timestamp = s.timestamp,
                                frameCount = s.frameCount,
                                updateCount = s.updateCount,
                                isRemote = s.isRemote,
                            };
                            watchEntries[s.key] = entry;
                            watchKeyOrder.Add(s.key);
                        }
                    }
                } catch { }
            }
        }

        [Serializable]
        private class SerializedLogList {
            public SerializedLog[] logs;
        }

        // ── Ignore List ──────────────────────────────────────────────────

        private static string IgnoreListPath {
            get {
                string projectId = EditorPrefs.GetString(PrefsPrefix + "ignoreListId", "");
                if (string.IsNullOrEmpty(projectId)) {
                    projectId = Application.dataPath.GetHashCode().ToString("X");
                    EditorPrefs.SetString(PrefsPrefix + "ignoreListId", projectId);
                }
                return Path.Combine(Path.GetTempPath(), "CustomConsole", $"ignore-list-{projectId}.txt");
            }
        }

        private static string GetIgnoreKey(LogEntry log) {
            // First line of message is the unique key for ignoring
            string firstLine = log.message;
            int nl = firstLine.IndexOf('\n');
            if (nl >= 0) firstLine = firstLine.Substring(0, nl);
            return firstLine.Trim();
        }

        private static void LoadIgnoreList() {
            ignoredLogKeys.Clear();
            string path = IgnoreListPath;
            if (!File.Exists(path)) return;
            try {
                foreach (string line in File.ReadAllLines(path, Encoding.UTF8)) {
                    if (!string.IsNullOrWhiteSpace(line)) ignoredLogKeys.Add(line);
                }
            } catch { }
        }

        private static void SaveIgnoreList() {
            string path = IgnoreListPath;
            string dir = Path.GetDirectoryName(path);
            if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
                Directory.CreateDirectory(dir);
            File.WriteAllLines(path, ignoredLogKeys, Encoding.UTF8);
        }

        private static void AddToIgnoreList(LogEntry log) {
            string key = GetIgnoreKey(log);
            if (ignoredLogKeys.Add(key)) {
                SaveIgnoreList();
            }
        }

        private static void OpenIgnoreList() {
            string path = IgnoreListPath;
            if (!File.Exists(path)) {
                // Create empty file so user can see it
                string dir = Path.GetDirectoryName(path);
                if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
                    Directory.CreateDirectory(dir);
                File.WriteAllText(path, "", Encoding.UTF8);
            }
            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo {
                FileName = path,
                UseShellExecute = true,
            });
        }

        private bool IsIgnored(LogEntry log) {
            return ignoredLogKeys.Contains(GetIgnoreKey(log));
        }

        private void ShowLogContextMenu(LogEntry log) {
            // Capture selection now — menu callbacks run later when the TextEditor state may be gone
            string selection = GetActiveTextSelection();
            var menu = new GenericMenu();
            menu.AddItem(new GUIContent("Copy"), false, () => {
                if (!string.IsNullOrEmpty(selection)) {
                    EditorGUIUtility.systemCopyBuffer = selection;
                } else {
                    string full = log.message;
                    if (!string.IsNullOrEmpty(log.stackTrace))
                        full += "\n" + log.stackTrace;
                    EditorGUIUtility.systemCopyBuffer = full;
                }
            });
            menu.AddSeparator("");
            if(log.pinned) {
                menu.AddItem(new GUIContent("Unpin"), false, () => {
                    log.pinned = false;
                    filterDirty = true;
                    Repaint();
                });
            }
            else {
                menu.AddItem(new GUIContent("Pin"), false, () => {
                    log.pinned = true;
                    filterDirty = true;
                    Repaint();
                });
            }
            menu.AddSeparator("");
            menu.AddItem(new GUIContent("Ignore Forever"), false, () => {
                AddToIgnoreList(log);
                filterDirty = true;
                Repaint();
            });
            menu.AddItem(new GUIContent("Show Ignore List"), false, OpenIgnoreList);
            menu.AddItem(new GUIContent("Reload Ignore List"), false, () => {
                LoadIgnoreList();
                filterDirty = true;
                Repaint();
            });
            menu.ShowAsContext();
        }

        // ── Export ────────────────────────────────────────────────────────

        private static void ExportLogs() {
            if (logs.Count == 0) {
                EditorUtility.DisplayDialog("Export Logs", "No logs to export.", "OK");
                return;
            }

            string path = EditorUtility.SaveFilePanel("Export Logs", "", $"Custom-log-{DateTime.Now:yyyy-MM-dd_HH-mm-ss}", "txt");
            if (string.IsNullOrEmpty(path)) return;

            var sb = new StringBuilder();
            foreach (var log in logs) {
                string source = log.isRemote
                    ? (log.isCustomLog ? "Remote-Custom" : "Remote-Unity")
                    : (log.isCustomLog ? "Custom" : "Unity");
                sb.AppendLine($"[{log.timestamp}] [F{log.frameCount}] [{log.type}] [{source}] {log.message}");
                if (!string.IsNullOrEmpty(log.stackTrace)) {
                    sb.AppendLine(log.stackTrace);
                }
            }

            File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
        }

        private static void ExportLogsForAI() {
            if (logs.Count == 0) {
                EditorUtility.DisplayDialog("Copy for AI", "No logs to copy.", "OK");
                return;
            }

            var sb = new StringBuilder();
            sb.AppendLine("# Unity Log Report");
            sb.AppendLine();
            sb.AppendLine("## Environment");
            sb.AppendLine($"- Unity: {Application.unityVersion}");
            sb.AppendLine($"- Platform: {Application.platform}");
            sb.AppendLine($"- Product: {Application.productName}");
#pragma warning disable CS0618 // Suppress obsolete warning for broad Unity version compatibility
            sb.AppendLine($"- Scripting Backend: {PlayerSettings.GetScriptingBackend(EditorUserBuildSettings.selectedBuildTargetGroup)}");
#pragma warning restore CS0618
            sb.AppendLine($"- Timestamp: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
            sb.AppendLine();

            // Summary counts
            int errors = 0, warnings = 0, exceptions = 0, asserts = 0, total = logs.Count;
            foreach (var log in logs) {
                switch (log.type) {
                    case JovianLogType.Error: errors++; break;
                    case JovianLogType.Warning: warnings++; break;
                    case JovianLogType.Exception: exceptions++; break;
                    case JovianLogType.Assert: asserts++; break;
                }
            }
            sb.AppendLine("## Summary");
            sb.AppendLine($"- Total: {total} | Errors: {errors} | Warnings: {warnings} | Exceptions: {exceptions} | Asserts: {asserts}");
            sb.AppendLine();

            // Watch variables
            if (watchEntries.Count > 0) {
                sb.AppendLine("## Watch Variables");
                foreach (var key in watchKeyOrder) {
                    if (watchEntries.TryGetValue(key, out var w)) {
                        sb.AppendLine($"- **{w.key}** = `{w.value}` (updated {w.updateCount}x, last: {w.timestamp})");
                    }
                }
                sb.AppendLine();
            }

            // Errors and exceptions first (most relevant for debugging)
            var critical = new List<LogEntry>();
            var other = new List<LogEntry>();
            foreach (var log in logs) {
                if (log.type is JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert)
                    critical.Add(log);
                else
                    other.Add(log);
            }

            if (critical.Count > 0) {
                sb.AppendLine("## Errors & Exceptions");
                sb.AppendLine();
                foreach (var log in critical) {
                    AppendAILogEntry(sb, log);
                }
            }

            if (other.Count > 0) {
                sb.AppendLine("## Log Messages");
                sb.AppendLine();
                // Cap non-critical to last 200 to keep clipboard manageable
                int start = other.Count > 200 ? other.Count - 200 : 0;
                if (start > 0)
                    sb.AppendLine($"_(showing last 200 of {other.Count} messages)_\n");
                for (int i = start; i < other.Count; i++) {
                    AppendAILogEntry(sb, other[i]);
                }
            }

            EditorGUIUtility.systemCopyBuffer = sb.ToString();
            Debug.Log($"[CustomConsole] Copied {total} log entries for AI ({sb.Length} chars)");
        }

        private static void AppendAILogEntry(StringBuilder sb, LogEntry log) {
            string source = log.isRemote
                ? (log.isCustomLog ? "Remote-Custom" : "Remote-Unity")
                : (log.isCustomLog ? "Custom" : "Unity");
            sb.AppendLine($"### [{log.type}] {log.timestamp} frame:{log.frameCount}");
            sb.AppendLine($"Source: {source} | File: {log.sourceFile ?? "?"} | Class: {log.className ?? "?"}");
            if (log.isCustomLog && log.logCategory != LogCategory.None)
                sb.AppendLine($"Category: {log.logCategory}");
            sb.AppendLine("```");
            sb.AppendLine(log.message);
            sb.AppendLine("```");
            if (!string.IsNullOrEmpty(log.stackTrace)) {
                sb.AppendLine("<details><summary>Stack Trace</summary>");
                sb.AppendLine();
                sb.AppendLine("```");
                sb.AppendLine(log.stackTrace.TrimEnd());
                sb.AppendLine("```");
                sb.AppendLine("</details>");
            }
            sb.AppendLine();
        }

        // ── Log Cache ─────────────────────────────────────────────────────

        private static readonly string LogCacheDir = Path.Combine(Path.GetTempPath(), "CustomConsole", "LogCache");
        private const int MaxCachedFiles = 10;

        private static void SaveLogCache() {
            if (logs.Count == 0) return;

            if (!Directory.Exists(LogCacheDir)) {
                Directory.CreateDirectory(LogCacheDir);
            }

            string fileName = $"log-cache-{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt";
            string filePath = Path.Combine(LogCacheDir, fileName);

            var sb = new StringBuilder();
            sb.AppendLine("=== Custom Console Log Cache ===");
            sb.AppendLine($"Saved: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
            sb.AppendLine($"Entries: {logs.Count}");
            sb.AppendLine(new string('=', 36));
            sb.AppendLine();

            foreach (var log in logs) {
                string source = log.isRemote
                    ? (log.isCustomLog ? "Remote-Custom" : "Remote-Unity")
                    : (log.isCustomLog ? "Custom" : "Unity");
                sb.AppendLine($"[{log.timestamp}] [{log.type}] [{source}] {log.message}");
                if (!string.IsNullOrEmpty(log.stackTrace)) {
                    sb.AppendLine(log.stackTrace);
                }
            }

            File.WriteAllText(filePath, sb.ToString(), Encoding.UTF8);

            // Prune old files beyond limit
            var files = new DirectoryInfo(LogCacheDir)
                .GetFiles("log-cache-*.txt")
                .OrderByDescending(f => f.CreationTime)
                .Skip(MaxCachedFiles)
                .ToArray();
            foreach (var file in files) {
                file.Delete();
            }
        }

        private static FileInfo[] GetCachedLogFiles() {
            if (!Directory.Exists(LogCacheDir)) return Array.Empty<FileInfo>();
            return new DirectoryInfo(LogCacheDir)
                .GetFiles("log-cache-*.txt")
                .OrderByDescending(f => f.CreationTime)
                .Take(MaxCachedFiles)
                .ToArray();
        }

        private static void ClearLogCache() {
            if (!Directory.Exists(LogCacheDir)) return;
            var files = new DirectoryInfo(LogCacheDir).GetFiles("log-cache-*.txt");
            foreach (var file in files) {
                try { file.Delete(); } catch { }
            }
            Debug.Log($"[CustomConsole] Cleared {files.Length} cached log file(s).");
        }

        private static void OpenCachedLog(string path) {
            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo {
                FileName = path,
                UseShellExecute = true,
            });
        }

        private static (string file, string className) ExtractCallerInfo(string stackTrace) {
            var match = FindCallerMatch(stackTrace);
            if (!match.Success) return ("", "");

            string file = string.Empty;
            string filePath = string.Empty;

            try {
                filePath = match.Groups[1].Value;
                file = Path.GetFileName(filePath);
            }
            catch {
                return (string.Empty, string.Empty);
            }

            // Parse class name from the stack frame line: "Namespace.Class.Method (args) (at file:line)"
            string line = match.Value;
            int atIdx = stackTrace.IndexOf(match.Value, StringComparison.Ordinal);
            if (atIdx > 0) {
                // Get the full line containing the match
                int lineStart = stackTrace.LastIndexOf('\n', atIdx - 1) + 1;
                string fullLine = stackTrace.Substring(lineStart, atIdx - lineStart).Trim();
                // fullLine looks like "Namespace.Class.Method (args)"
                int parenIdx = fullLine.IndexOf('(');
                if (parenIdx > 0) fullLine = fullLine.Substring(0, parenIdx).Trim();
                // Now "Namespace.Class.Method" — take second-to-last dot segment
                int lastDot = fullLine.LastIndexOf('.');
                if (lastDot > 0) {
                    int prevDot = fullLine.LastIndexOf('.', lastDot - 1);
                    string cls = prevDot >= 0 ? fullLine.Substring(prevDot + 1, lastDot - prevDot - 1) : fullLine.Substring(0, lastDot);
                    return (file, cls);
                }
            }

            return (file, "");
        }

        private class WatchEntry {
            public string key;
            public string value;
            public LogCategory logCategory;
            public string timestamp;
            public int frameCount;
            public int updateCount;
            public bool isRemote;
        }

        private class LogEntry {
            public string message;
            public string stackTrace;
            public string timestamp;
            public int frameCount;
            public string sourceFile;
            public string className;
            public JovianLogType type;
            public LogCategory logCategory;
            public bool isCustomLog;
            public bool isRemote;
            public bool pinned;
            public bool expanded;
            public bool isNew = true;
            public bool isCompileError;
            public bool isCompileWarning;

            public LogEntry(JovianLogType type, LogCategory logCategory, string message, string stackTrace, bool isCustomLog) {
                this.message = message;
                this.stackTrace = stackTrace;
                this.timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
                this.frameCount = LoggerUtility.FrameCount;
                this.type = type;
                this.logCategory = logCategory;
                this.isCustomLog = isCustomLog;
                var info = ExtractCallerInfo(stackTrace);
                this.sourceFile = info.file;
                this.className = info.className;
            }
        }
    }

#if UNITY_6000_3_OR_NEWER
    static class CustomConsoleMainToolbar {
        const string k_ElementPath = "Custom/Console";

        [MainToolbarElement(k_ElementPath, defaultDockPosition = MainToolbarDockPosition.Right)]
        static MainToolbarButton CreateButton() {
            return new MainToolbarButton(
                new MainToolbarContent("Custom Console", "Open Custom Console"),
                () => CustomConsole.ShowWindow()
            );
        }
    }
#endif
}
