using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Runtime.ExceptionServices;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Windowing;
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using HellionChat.Ipc;
using HellionChat.Resources;
using HellionChat.Ui;
using HellionChat.Util;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace HellionChat;

// ReSharper disable once ClassNeverInstantiated.Global
public sealed class Plugin : IAsyncDalamudPlugin
{
    public const string PluginName = "Hellion Chat";

    [PluginService]
    public static IPluginLog Log { get; private set; } = null!;

    [PluginService]
    public static IDalamudPluginInterface Interface { get; private set; } = null!;

    [PluginService]
    public static IChatGui ChatGui { get; private set; } = null!;

    [PluginService]
    public static IClientState ClientState { get; private set; } = null!;

    [PluginService]
    public static ICommandManager CommandManager { get; private set; } = null!;

    [PluginService]
    public static ICondition Condition { get; private set; } = null!;

    [PluginService]
    public static IDataManager DataManager { get; private set; } = null!;

    [PluginService]
    public static IFramework Framework { get; private set; } = null!;

    [PluginService]
    public static IGameGui GameGui { get; private set; } = null!;

    [PluginService]
    public static IKeyState KeyState { get; private set; } = null!;

    [PluginService]
    public static IObjectTable ObjectTable { get; private set; } = null!;

    [PluginService]
    public static IPartyList PartyList { get; private set; } = null!;

    [PluginService]
    public static ITargetManager TargetManager { get; private set; } = null!;

    [PluginService]
    public static ITextureProvider TextureProvider { get; private set; } = null!;

    [PluginService]
    public static IGameInteropProvider GameInteropProvider { get; private set; } = null!;

    [PluginService]
    public static IGameConfig GameConfig { get; private set; } = null!;

    [PluginService]
    public static INotificationManager Notification { get; private set; } = null!;

    [PluginService]
    public static IAddonLifecycle AddonLifecycle { get; private set; } = null!;

    [PluginService]
    public static IPlayerState PlayerState { get; private set; } = null!;

    [PluginService]
    public static ISeStringEvaluator Evaluator { get; private set; } = null!;

    [PluginService]
    public static ISelfTestRegistry SelfTestRegistry { get; private set; } = null!;

    public static Configuration Config = null!;
    public static FileDialogManager FileDialogManager { get; private set; } = null!;

    public readonly WindowSystem WindowSystem = new(PluginName);

    // Phase-2 services are constructed in LoadAsync; null! shape is kept
    // consistent across all properties for clarity.
    public SettingsWindow SettingsWindow { get; private set; } = null!;
    public ChatLogWindow ChatLogWindow { get; private set; } = null!;
    public DbViewer DbViewer { get; private set; } = null!;
    public InputPreview InputPreview { get; private set; } = null!;
    public CommandHelpWindow CommandHelpWindow { get; private set; } = null!;
    public SeStringDebugger SeStringDebugger { get; private set; } = null!;
    public FirstRunWizard FirstRunWizard { get; private set; } = null!;
    public DebuggerWindow DebuggerWindow { get; private set; } = null!;

    internal Commands Commands { get; private set; } = null!;
    internal GameFunctions.GameFunctions Functions { get; private set; } = null!;
    internal MessageManager MessageManager { get; private set; } = null!;
    internal AutoTellTabsService AutoTellTabsService { get; private set; } = null!;
    internal IpcManager Ipc { get; private set; } = null!;
    internal ExtraChat ExtraChat { get; private set; } = null!;
    internal TypingIpc TypingIpc { get; private set; } = null!;
    internal FontManager FontManager { get; private set; } = null!;
    internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!;
    internal Ui.StatusBar StatusBar { get; private set; } = null!;
    internal Integrations.HonorificService HonorificService { get; private set; } = null!;
    internal Integrations.CustomAudioPlayer CustomAudioPlayer { get; private set; } = null!;

    // Platform indirection over Dalamud.Utility.Util. Wired in Phase-1 ctor so
    // any service allocated in LoadAsync can read Plugin.PlatformUtil.
    internal static IPlatformUtil PlatformUtil { get; private set; } = null!;

    // Log indirection over Dalamud's IPluginLog. Same rationale as PlatformUtil:
    // call-sites read through LogProxy so MessageStore can be tested in
    // isolation. Wired immediately after Dalamud injects Log.
    internal static IPluginLogProxy LogProxy { get; private set; } = null!;

    // Nullable so DisposeAsync stays safe if Host-build throws before the
    // fields get assigned — Dalamud fires DisposeAsync regardless.
    private readonly IHost? _host;
    private readonly PluginLifecycle? _lifecycle;

    // Wrapper cached so TearDown can detach the live instance instead of
    // re-registering with identical args (v1.4.9 ISSUE-1 cleanup).
    private CommandWrapper? _hellionSettingsCmd;
    private CommandWrapper? _hellionViewCmd;
    private CommandWrapper? _hellionDebuggerCmd;
#if DEBUG
    private CommandWrapper? _hellionSeStringCmd;
#endif

    // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
    private int _disposeStarted;

    // Set in the first DisposeAsync statement so async callbacks scheduled
    // via Framework.RunOnTick (v1.4.8 B3 retention sweep) can early-bail
    // before they touch state that has already been torn down. Volatile
    // because the tick reads it from a different thread than the writer.
    private volatile bool _isDisposing;

    internal int DeferredSaveFrames = -1;

    // Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The
    // worker runs off the framework thread on its own SqliteConnection, so a
    // Dispose mid-rebuild must signal cancellation before MessageManager
    // tears down (the worker logs "rebuild failed" via Log on error paths).
    private CancellationTokenSource? _ftsRebuildCts;

    // Serialises retention sweeps so a manual trigger and the 24h auto-sweep
    // can't run in parallel. Volatile because the ImGui thread reads it outside
    // the lock to gate the manual button.
    internal readonly object RetentionSweepLock = new();
    internal volatile bool RetentionSweepRunning;

    internal DateTime GameStarted { get; }

    // Tab management lives here rather than in ChatLogWindow for access reasons.
    internal int LastTab { get; set; }
    internal int? WantedTab { get; set; }
    internal Tab CurrentTab
    {
        get
        {
            var i = LastTab;
            return i > -1 && i < Config.Tabs.Count ? Config.Tabs[i] : new Tab();
        }
    }

    public Plugin()
    {
        // Phase-1 ctor: bootstrap-essentials only (conflict gate, config load,
        // language + ImGui init). All service/window allocation lives in LoadAsync.

        // Block load if upstream Chat 2 is active — prevents IPC collisions
        // and double-replacement of the in-game chat window.
        ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface);

        GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime();

        // Migrate config + database from upstream ChatTwo on first start.
        MigrateFromChatTwoLayout();

        Config = Interface.GetPluginConfig() as Configuration ?? new Configuration();

        // PlatformUtil and LogProxy are filled from the DI container in
        // Phase-1 below (`_host.Services.GetRequiredService<IPlatformUtil>()`
        // and the LogProxy equivalent). Phase-0 helpers that run before that
        // point (MigrateFromChatTwoLayout, LanguageChanged, ImGuiUtil.Initialize)
        // do not touch either static, so the brief null-window is safe.

        // Schema gate: v1.4.x+ requires config v16+. Users on older schemas
        // must install v1.4.2 first to run the migration chain. v19 adds the
        // top-level CustomSoundVolume, WindowOpacityInactive, WorldSuffixMode
        // and NameFormMode fields — all additive with defaults, so v16-v18
        // configs load cleanly and get their Version stamp bumped after the
        // gate.
        if (Config.Version < 16)
        {
            throw new InvalidOperationException(
                $"HellionChat v1.4.10 requires config schema v16, got v{Config.Version}. "
                    + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.10."
            );
        }
        Config.Version = 19;

        // Unpinned TempTabs are session-only and dropped on every load. Pinned
        // TempTabs survive reload — Jin's tester feedback (v1.4.7).
        Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnLoad);

        LanguageChanged(Interface.UiLanguage);

        // v1.5.3 migration: Settings.Apply auto-activates the matching
        // ExtraGlyphRanges flag on a language CHANGE; a config that already
        // has e.g. Czech selected from a previous version never goes through
        // that path. ORing in the required flag here lets the first atlas
        // build pick it up, so an upgrade from v1.5.2 renders correctly
        // without forcing the user to toggle the language twice.
        var requiredRanges = Config.LanguageOverride.RequiredGlyphRanges();
        if (requiredRanges != 0 && !Config.ExtraGlyphRanges.HasFlag(requiredRanges))
            Config.ExtraGlyphRanges |= requiredRanges;

        ImGuiUtil.Initialize(this);

        DeferredSaveFrames = -1;

        // Custom themes dir + seed run before the container builds so the
        // ThemeRegistry factory lambda finds the directory ready.
        var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes");
        Directory.CreateDirectory(customThemesDir);
        SeedExampleThemeIfEmpty(customThemesDir);

        // Phase-1: build the host synchronously (the schema gate must clear
        // before services allocate; Lightless' deferred build would invert
        // that order) and pull singletons into the Plugin.X surface.
        var dependencies = new PluginHostDependencies(
            Interface,
            Log,
            ChatGui,
            ClientState,
            CommandManager,
            Condition,
            DataManager,
            Framework,
            GameGui,
            KeyState,
            ObjectTable,
            PartyList,
            TargetManager,
            TextureProvider,
            GameInteropProvider,
            GameConfig,
            Notification,
            AddonLifecycle,
            PlayerState,
            Evaluator,
            SelfTestRegistry
        );

        _host = PluginHostFactory.Build(this, dependencies);
        _lifecycle = _host.Services.GetRequiredService<PluginLifecycle>();
        _lifecycle.Host = _host;

        // Plugin.X static bridge - filled from the container so DI-aware code
        // and the ~93 Plugin.X consumer sites read the same instances.
        PlatformUtil = _host.Services.GetRequiredService<IPlatformUtil>();
        LogProxy = _host.Services.GetRequiredService<IPluginLogProxy>();
        FileDialogManager = _host.Services.GetRequiredService<FileDialogManager>();

        // Resolve order matters: block-B services first so the windows can
        // read Plugin.MessageManager etc. from their own ctors without NREs.
        FontManager = _host.Services.GetRequiredService<FontManager>();
        ThemeRegistry = _host.Services.GetRequiredService<Themes.ThemeRegistry>();
        Commands = _host.Services.GetRequiredService<Commands>();
        Functions = _host.Services.GetRequiredService<GameFunctions.GameFunctions>();
        Ipc = _host.Services.GetRequiredService<IpcManager>();
        TypingIpc = _host.Services.GetRequiredService<TypingIpc>();
        ExtraChat = _host.Services.GetRequiredService<ExtraChat>();
        HonorificService = _host.Services.GetRequiredService<Integrations.HonorificService>();
        CustomAudioPlayer = _host.Services.GetRequiredService<Integrations.CustomAudioPlayer>();
        StatusBar = _host.Services.GetRequiredService<Ui.StatusBar>();
        MessageManager = _host.Services.GetRequiredService<MessageManager>();
        AutoTellTabsService = _host.Services.GetRequiredService<AutoTellTabsService>();

        ChatLogWindow = _host.Services.GetRequiredService<ChatLogWindow>();
        SettingsWindow = _host.Services.GetRequiredService<SettingsWindow>();
        DbViewer = _host.Services.GetRequiredService<DbViewer>();
        InputPreview = _host.Services.GetRequiredService<InputPreview>();
        CommandHelpWindow = _host.Services.GetRequiredService<CommandHelpWindow>();
        SeStringDebugger = _host.Services.GetRequiredService<SeStringDebugger>();
        DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
        FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
    }

    public async Task LoadAsync(CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        try
        {
            // Default tab layout on fresh install. Tells are handled by
            // Auto-Tell-Tabs; Novice Network has no preset tab by design.
            if (Config.Tabs.Count == 0)
            {
                Config.Tabs.Add(TabsUtil.VanillaGeneral);
                Config.Tabs.Add(TabsUtil.HellionSystem);
                Config.Tabs.Add(TabsUtil.HellionFreeCompany);
                Config.Tabs.Add(TabsUtil.HellionParty);
                Config.Tabs.Add(TabsUtil.HellionLinkshell);
            }

            cancellationToken.ThrowIfCancellationRequested();

            // Container drives service init now: Host.StartAsync triggers the
            // remaining IHostedService adapters (ThemeRegistry cache warmup +
            // Switch, IPC eager-resolve, MessageManager FilterAllTabsAsync,
            // AutoTellTabsService.Initialize). FontManager runs its own init
            // inline inside the ctor's SuppressAutoRebuild block on eager
            // resolve. Window registration with WindowSystem runs on the
            // framework thread inside PluginLifecycle.LoadAsync after
            // StartAsync returns.
            if (_lifecycle is not null)
                await _lifecycle.LoadAsync(cancellationToken).ConfigureAwait(false);

            SelfTestRegistry.RegisterTestSteps([
                new SelfTests.ThemeSwitchSelfTestStep(this),
                new SelfTests.ThemeCrossfadeSelfTestStep(this),
                new SelfTests.FontManagerCtorSmokeStep(this),
                new SelfTests.FontPushSmokeStep(this),
                new SelfTests.WizardStateSmokeStep(this),
                new SelfTests.QuickPickerSelfTestStep(this),
                new SelfTests.FoxBannerTextureSmokeStep(this),
            ]);

            // Re-surface the wizard for existing users when a major UX
            // rework ships. The constant tracks the most recent version
            // whose wizard should be shown once; bump it in future cycles
            // that reshape the onboarding flow. Saved immediately so a
            // pre-Finish crash doesn't loop the prompt forever.
            const string WizardReshowVersion = "1.5.2";
            if (Config.WizardLastShownVersion != WizardReshowVersion)
            {
                Config.FirstRunCompleted = false;
                Config.WizardLastShownVersion = WizardReshowVersion;
                SaveConfig();
            }

            if (!Config.FirstRunCompleted)
                FirstRunWizard.IsOpen = true;

            cancellationToken.ThrowIfCancellationRequested();

            // Populate the command dictionary + UiBuilder hooks BEFORE
            // Commands.Initialise() walks the dictionary and registers each
            // entry with Dalamud's CommandManager (Commands.cs:15-28). Adding
            // wrappers after Initialise() would leak them — they'd live in
            // the dictionary but never reach Dalamud.
            SetupCommands();
            Commands.Initialise();

            // Daily retention sweep — fire-and-forget, skips when disabled
            // or already ran within the past 24 hours.
            RunRetentionSweepIfDue();

            if (Config.ShowEmotes)
                _ = EmoteCache.LoadData();

            // FilterAllTabsAsync now runs from MessageManagerInitHostedService
            // during Host.StartAsync (same Reason-not-Boot guard there).

            // Kick the FTS5 rebuild worker if Migrate4 just added the schema or
            // a previous run was cut short (InitFtsReadyCache leaves _ftsReady
            // false in that case). Runs off the framework thread on its own
            // SqliteConnection so the live UpsertMessage path keeps flowing
            // through the chunked-commit windows.
            _ftsRebuildCts = new CancellationTokenSource();
            if (!MessageManager.Store.IsFtsIndexBuilt)
            {
                var token = _ftsRebuildCts.Token;
                _ = Task.Run(
                    async () =>
                    {
                        // FQN: Plugin.Notification (Z.74) shadows the type name.
                        Dalamud.Interface.ImGuiNotification.IActiveNotification? notif = null;
                        try
                        {
                            notif = Notification.AddNotification(
                                new Dalamud.Interface.ImGuiNotification.Notification
                                {
                                    Title = "Hellion Chat",
                                    Content = "Indexing chat history for full-text search...",
                                    Type = Dalamud
                                        .Interface
                                        .ImGuiNotification
                                        .NotificationType
                                        .Info,
                                    Minimized = false,
                                    InitialDuration = TimeSpan.FromMinutes(10),
                                }
                            );

                            // Progress<T> raises this callback on the captured
                            // sync-context (Task.Run worker pool). IActiveNotification
                            // is ImGui-backed and mutates the UI, so marshal the
                            // mutation onto the framework thread via RunOnTick.
                            var progress = new Progress<long>(done =>
                            {
                                Framework.RunOnTick(() =>
                                {
                                    if (notif is { } n)
                                        n.Content = $"Indexing chat history: {done:N0} messages...";
                                });
                            });

                            // Worker-owned connection. Closed+disposed before we
                            // flip the readiness flag so the DbViewer never sees
                            // IsFtsIndexBuilt=true while the worker connection
                            // is still alive.
                            SqliteConnection? workerConn = null;
                            try
                            {
                                workerConn = MessageManager.Store.OpenSecondaryConnection();
                                var total = await Task.Run(
                                        () =>
                                            MessageManager.Store.RebuildFtsIndex(
                                                workerConn,
                                                progress,
                                                token
                                            ),
                                        token
                                    )
                                    .ConfigureAwait(false);

                                workerConn.Close();
                                workerConn.Dispose();
                                workerConn = null;
                                MessageManager.Store.MarkFtsIndexBuilt();

                                if (notif is { } final)
                                {
                                    final.Content = $"Indexed {total:N0} messages.";
                                    final.Type = Dalamud
                                        .Interface
                                        .ImGuiNotification
                                        .NotificationType
                                        .Success;
                                    final.InitialDuration = TimeSpan.FromSeconds(5);
                                }
                            }
                            finally
                            {
                                workerConn?.Dispose();
                            }
                        }
                        catch (OperationCanceledException)
                        {
                            notif?.DismissNow();
                        }
                        catch (Exception ex)
                        {
                            Log.Error(ex, "FTS index rebuild failed");
                            if (notif is { } err)
                            {
                                err.Content =
                                    "Full-text indexing failed -- search will use local filter only.";
                                err.Type = Dalamud
                                    .Interface
                                    .ImGuiNotification
                                    .NotificationType
                                    .Error;
                            }
                        }
                    },
                    _ftsRebuildCts.Token
                );
            }

            Interface.UiBuilder.DisableCutsceneUiHide = true;
            Interface.UiBuilder.DisableGposeUiHide = true;

#if !DEBUG
            // Fire-and-forget — first auto-translate use may have a sub-second
            // hitch if the cache hasn't filled yet, but avoids blocking load.
            _ = Task.Run(AutoTranslate.PreloadCache, cancellationToken);
#endif

            cancellationToken.ThrowIfCancellationRequested();

            // Hooks last — all services and windows must be live before
            // the first Draw / FrameworkUpdate tick fires.
            Framework.Update += FrameworkUpdate;
            Interface.UiBuilder.Draw += Draw;
            Interface.LanguageChanged += LanguageChanged;
        }
        catch
        {
            try
            {
                await DisposeAsync().ConfigureAwait(false);
            }
            catch
            { /* keep original failure */
            }
            throw;
        }
    }

    [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")]
    public async ValueTask DisposeAsync()
    {
        // Idempotency guard — second call short-circuits on reload race.
        if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
            return;

        // Set before any cleanup so deferred Framework.RunOnTick callbacks
        // (B3 retention sweep) see the flag and bail out before they touch
        // MessageManager / Log / static fields that the rest of this method
        // is about to tear down.
        _isDisposing = true;

        Exception? failure = null;

        // Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync.
        failure = CaptureFailure(failure, () => Interface.LanguageChanged -= LanguageChanged);
        failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw);
        failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate);

        // Signal the FTS rebuild worker to bail. Runs before MessageManager
        // tears down so the worker's "rebuild failed" log path still finds
        // a live Log static. Worker owns its own SqliteConnection and disposes
        // it itself; we only flip the cancellation flag here.
        failure = CaptureFailure(
            failure,
            () =>
            {
                _ftsRebuildCts?.Cancel();
                _ftsRebuildCts?.Dispose();
            }
        );

        // Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore.
        failure = CaptureFailure(
            failure,
            () =>
            {
                if (DeferredSaveFrames >= 0)
                {
                    SaveConfig();
                    DeferredSaveFrames = -1;
                }
            }
        );

        // Framework-thread cleanup the container does not reach.
        try
        {
            await Framework
                .RunOnFrameworkThread(() =>
                {
                    failure = CaptureFailure(failure, TearDownCommands);
                    failure = CaptureFailure(
                        failure,
                        () => GameFunctions.GameFunctions.SetChatInteractable(true)
                    );
                    failure = CaptureFailure(failure, () => WindowSystem?.RemoveAllWindows());
                })
                .ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            failure ??= ex;
        }

        // Container disposes services + windows on the framework thread.
        // MessageManager.DisposeAsync is not idempotent, so we let the
        // container do it once instead of double-disposing.
        if (_lifecycle is not null)
        {
            failure = await CaptureFailureAsync(failure, () => _lifecycle.DisposeAsync().AsTask())
                .ConfigureAwait(false);
        }

        // Static-class cleanups the container has no handle on.
        failure = CaptureFailure(failure, () => EmoteCache.Dispose());
        failure = CaptureFailure(failure, InputHistoryService.Reset);

        if (failure is not null)
            ExceptionDispatchInfo.Capture(failure).Throw();
    }

    // Run cleanup actions individually so a single failure doesn't strand
    // the remaining teardown steps.
    private static Exception? CaptureFailure(Exception? failure, Action action)
    {
        try
        {
            action();
        }
        catch (Exception ex)
        {
            failure ??= ex;
        }
        return failure;
    }

    private static async ValueTask<Exception?> CaptureFailureAsync(
        Exception? failure,
        Func<Task> action
    )
    {
        try
        {
            await action().ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            failure ??= ex;
        }
        return failure;
    }

    private static void MigrateFromChatTwoLayout()
    {
        var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
        if (pluginConfigsDir is null)
            return;

        var legacyConfigFile = Path.Combine(pluginConfigsDir, "ChatTwo.json");
        var legacyConfigDir = Path.Combine(pluginConfigsDir, "ChatTwo");
        var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json");
        var ourConfigDir = Interface.ConfigDirectory.FullName;

        var lockedBlocker = false;

        try
        {
            if (!File.Exists(ourConfigFile) && File.Exists(legacyConfigFile))
            {
                File.Move(legacyConfigFile, ourConfigFile);
                Log.Information(
                    $"HellionChat: migrated config file {legacyConfigFile} → {ourConfigFile}"
                );
            }
        }
        catch (IOException e)
        {
            Log.Warning(
                e,
                $"HellionChat: config file move blocked, leaving {legacyConfigFile} in place"
            );
            lockedBlocker = true;
        }

        if (!Directory.Exists(legacyConfigDir))
            return;

        try
        {
            Directory.CreateDirectory(ourConfigDir);

            // Move each file individually so a single locked file (e.g. the
            // SQLite db while ChatTwo is still loaded) doesn't abort the rest.
            foreach (var file in Directory.EnumerateFiles(legacyConfigDir))
            {
                var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
                if (File.Exists(target))
                    continue;
                try
                {
                    File.Move(file, target);
                    Log.Information($"HellionChat: migrated file {file} → {target}");
                }
                catch (IOException e)
                {
                    Log.Warning(
                        e,
                        $"HellionChat: file move blocked for {file}, will retry on next load"
                    );
                    lockedBlocker = true;
                }
            }

            foreach (var dir in Directory.EnumerateDirectories(legacyConfigDir))
            {
                var target = Path.Combine(ourConfigDir, Path.GetFileName(dir));
                if (Directory.Exists(target))
                    continue;
                try
                {
                    Directory.Move(dir, target);
                    Log.Information($"HellionChat: migrated subdir {dir} → {target}");
                }
                catch (IOException e)
                {
                    Log.Warning(
                        e,
                        $"HellionChat: subdir move blocked for {dir}, will retry on next load"
                    );
                    lockedBlocker = true;
                }
            }

            if (!Directory.EnumerateFileSystemEntries(legacyConfigDir).Any())
            {
                Directory.Delete(legacyConfigDir);
                Log.Information($"HellionChat: removed empty legacy dir {legacyConfigDir}");
            }
        }
        catch (Exception e)
        {
            Log.Error(e, "HellionChat: layout migration failed, continuing with whatever exists");
        }

        if (lockedBlocker)
        {
            Notification.AddNotification(
                new Dalamud.Interface.ImGuiNotification.Notification
                {
                    Title = "Hellion Chat",
                    Content =
                        "Could not migrate the Chat 2 database — the file appears to be in use. "
                        + "Disable Chat 2, fully close the game, then start it again. "
                        + "See the README troubleshooting section if the issue persists.",
                    Type = Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
                    InitialDuration = TimeSpan.FromSeconds(30),
                }
            );
        }
    }

    // Central slash-command + UiBuilder.OpenConfigUi/OpenMainUi subscribe so
    // the four lazy windows (Settings, DbViewer, SeStringDebugger, Debugger)
    // have working entry points before they're constructed.
    private void SetupCommands()
    {
        // ChatLogWindow.cs:128 already registers /hellion (ToggleChat). The
        // description-arg here keeps the Dalamud help list populated.
        _hellionSettingsCmd = Commands.Register(
            "/hellion",
            "Perform various actions with Hellion Chat."
        );
        _hellionSettingsCmd.Execute += OnHellionSettingsCommand;

        _hellionViewCmd = Commands.Register(
            "/hellionView",
            "Get access to your message history, with simple filter options.",
            true
        );
        _hellionViewCmd.Execute += OnHellionViewCommand;

        _hellionDebuggerCmd = Commands.Register("/hellionDebugger", showInHelp: false);
        _hellionDebuggerCmd.Execute += OnHellionDebuggerCommand;
#if DEBUG
        // SeStringDebugger.cs lives under #if DEBUG too; keep this out of release builds.
        _hellionSeStringCmd = Commands.Register("/hellionSeString", showInHelp: false);
        _hellionSeStringCmd.Execute += OnHellionSeStringCommand;
#endif

        // Plugin-Manager "Settings" button. Was in Settings.cs:67 pre-v1.4.9.
        Interface.UiBuilder.OpenConfigUi += OnOpenConfigUi;

        // Plugin-Manager "Open" button. Was in Plugin.cs LoadAsync pre-v1.4.9
        // (separate OpenMainUi handler that flipped SettingsWindow.IsOpen).
        Interface.UiBuilder.OpenMainUi += OnOpenMainUi;
    }

    private void TearDownCommands()
    {
        Interface.UiBuilder.OpenMainUi -= OnOpenMainUi;
        Interface.UiBuilder.OpenConfigUi -= OnOpenConfigUi;

        // Null-tolerant detaches: TearDownCommands can run from the LoadAsync
        // failure path (Plugin.cs CaptureFailure) before SetupCommands finished.
        if (_hellionSettingsCmd is not null)
        {
            _hellionSettingsCmd.Execute -= OnHellionSettingsCommand;
            _hellionSettingsCmd = null;
        }

        if (_hellionViewCmd is not null)
        {
            _hellionViewCmd.Execute -= OnHellionViewCommand;
            _hellionViewCmd = null;
        }

        if (_hellionDebuggerCmd is not null)
        {
            _hellionDebuggerCmd.Execute -= OnHellionDebuggerCommand;
            _hellionDebuggerCmd = null;
        }
#if DEBUG
        if (_hellionSeStringCmd is not null)
        {
            _hellionSeStringCmd.Execute -= OnHellionSeStringCommand;
            _hellionSeStringCmd = null;
        }
#endif
    }

    private void OnHellionSettingsCommand(string command, string arguments)
    {
        // /hellion with args is intentionally a no-op (matches pre-v1.4.9
        // Settings.cs:76-80 behaviour).
        if (string.IsNullOrWhiteSpace(arguments))
            SettingsWindow.Toggle();
    }

    private void OnOpenConfigUi() => SettingsWindow.Toggle();

    private void OnOpenMainUi() => SettingsWindow.Toggle();

    private void OnHellionViewCommand(string _, string __) => DbViewer.Toggle();

    private void OnHellionDebuggerCommand(string _, string __) => DebuggerWindow.Toggle();

#if DEBUG
    private void OnHellionSeStringCommand(string _, string __) => SeStringDebugger.Toggle();
#endif

    private void RunRetentionSweepIfDue()
    {
        if (!Config.RetentionEnabled)
            return;
        if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
            return;

        // Snapshot the policy so the user can edit settings while the sweep runs.
        var policy = new Dictionary<int, int>();
        foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
            policy[(int)(ushort)type] = days;
        foreach (var (type, days) in Config.RetentionPerChannelDays)
            policy[(int)(ushort)type] = days;
        var defaultDays = Config.RetentionDefaultDays;

        // IsBackground = true so a stuck sweep never blocks plugin unload.
        new Thread(() =>
        {
            // Bail early if a manual sweep is already in flight.
            lock (RetentionSweepLock)
            {
                if (RetentionSweepRunning)
                    return;
                RetentionSweepRunning = true;
            }

            try
            {
                var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
                Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
                SaveConfig();

                if (deleted > 0)
                {
                    Log.Information($"Retention sweep deleted {deleted} expired messages.");
                    // Schedule on the next framework tick to avoid the ~194ms
                    // hitch from blocking with .Wait() while the framework
                    // finishes the current frame. Tabs-list mutation must
                    // stay on the framework thread because Plugin.Config.Tabs
                    // (Configuration.cs:222) is not lock-protected and
                    // AutoTellTabsService can mutate it from background paths.
                    // Pattern reference: SimpleTweaks
                    // Tweaks/Chat/CaseInsensitiveCommands.cs:45.
                    Framework.RunOnTick(() =>
                    {
                        // The retention thread is IsBackground=true so plugin
                        // unload can fire while a scheduled tick is still
                        // pending; bail before touching anything torn down.
                        if (_isDisposing)
                            return;
                        try
                        {
                            MessageManager.ClearAllTabs();
                            MessageManager.FilterAllTabs();
                        }
                        catch (Exception ex)
                        {
                            Log.Error(ex, "Retention sweep clear+refilter failed");
                        }
                    });
                }
                else
                {
                    Log.Information("Retention sweep ran, nothing expired.");
                }
            }
            catch (Exception e)
            {
                Log.Error(e, "Retention sweep failed");
            }
            finally
            {
                lock (RetentionSweepLock)
                    RetentionSweepRunning = false;
            }
        })
        {
            IsBackground = true,
        }.Start();
    }

    private void Draw()
    {
        // v1.4.8 B2: pick up external edits of the active custom theme JSON
        // without forcing the user to re-click the picker. The disk-stat is
        // 1Hz-throttled inside RefreshActiveIfStale, so this is essentially
        // free on built-in themes and ~1 stat/second on custom themes.
        ThemeRegistry.RefreshActiveIfStale();

        // Theme engine is always active; Classic is a theme, not a disabled state.
        using IDisposable _style = HellionStyle.PushGlobal(
            ThemeRegistry.Active,
            ThemeRegistry,
            Config.WindowOpacity
        );

        ChatLogWindow.BeginFrame();

        if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas])
        {
            ChatLogWindow.FinalizeFrame();
            TypingIpc.Update();
            return;
        }

        // Hide all plugin windows while the New Game+ menu is open.
        if (
            Config.HideInNewGamePlusMenu
            && GameFunctions.GameFunctions.IsAddonInteractable(
                GameFunctions.GameFunctions.NewGamePlusAddonName
            )
        )
        {
            ChatLogWindow.FinalizeFrame();
            TypingIpc.Update();
            return;
        }

        ChatLogWindow.HideStateCheck();

        Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden;
        ChatLogWindow.DefaultText = ImGui.GetStyle().Colors[(int)ImGuiCol.Text];

        // RegularFont is nullable only because the live rebuild path
        // disposes it before reassigning; both ends of that swap happen on
        // this same draw thread, so it cannot be null here.
        // v1.5.3 fix: also push RegularFont when the bundled Inter Light is
        // selected. Without this, UseHellionFont=true silently fell back to
        // the FFXIV Axis font because the Appearance tab forces FontsEnabled
        // off in that branch, and the bundled font never made it into draw.
        var useRegularFont = Config.FontsEnabled || Config.UseHellionFont;
        using ((useRegularFont ? FontManager.RegularFont! : FontManager.Axis).Push())
            WindowSystem.Draw();

        ChatLogWindow.FinalizeFrame();
        TypingIpc.Update();

        FileDialogManager.Draw();
    }

    internal void SaveConfig()
    {
        // Only unpinned TempTabs are session-only — they move aside before
        // serialization and re-attach after. Pinned TempTabs stay in
        // Config.Tabs across the save so JSON includes them. Cloning only the
        // unpinned subset keeps the allocation proportional to
        // AutoTellTabsLimit (<=15) instead of the full tab list.
        var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
        Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);

        Interface.SavePluginConfig(Config);

        Config.Tabs.AddRange(unpinnedTempTabs);
    }

    internal void LanguageChanged(string langCode)
    {
        var info =
            Config.LanguageOverride is LanguageOverride.None
                ? new CultureInfo(langCode)
                : new CultureInfo(Config.LanguageOverride.Code());

        Language.Culture = info;
        HellionStrings.Culture = info;
    }

    private static readonly string[] ChatAddonNames =
    [
        "ChatLog",
        "ChatLogPanel_0",
        "ChatLogPanel_1",
        "ChatLogPanel_2",
        "ChatLogPanel_3",
    ];

    private void FrameworkUpdate(IFramework framework)
    {
        if (DeferredSaveFrames >= 0 && DeferredSaveFrames-- == 0)
            SaveConfig();

        if (!Config.HideChat)
            return;

        foreach (var name in ChatAddonNames)
            if (GameFunctions.GameFunctions.IsAddonInteractable(name))
                GameFunctions.GameFunctions.SetAddonInteractable(name, false);
    }

    public static bool InBattle => Condition[ConditionFlag.InCombat];
    public static bool GposeActive => Condition[ConditionFlag.WatchingCutscene];
    public static bool CutsceneActive =>
        Condition[ConditionFlag.OccupiedInCutSceneEvent]
        || Condition[ConditionFlag.WatchingCutscene78];

    // Seeds example-theme.json into the themes dir on first run.
    // Skipped if any custom JSON already exists.
    private static void SeedExampleThemeIfEmpty(string dir)
    {
        if (Directory.EnumerateFiles(dir, "*.json").Any())
            return;

        var examplePath = Path.Combine(dir, "example-theme.json");
        var resourceStream = typeof(Plugin).Assembly.GetManifestResourceStream(
            "HellionChat.Themes.Builtin.example-theme.json"
        );
        if (resourceStream is null)
        {
            Log.Warning("Themes example template not found in assembly resources; skipping seed.");
            return;
        }

        try
        {
            using var fileStream = File.Create(examplePath);
            resourceStream.CopyTo(fileStream);
            Log.Information($"Seeded example-theme.json into {dir}");
        }
        catch (IOException ex)
        {
            Log.Warning(
                ex,
                "Failed to seed example-theme.json; user can create custom themes manually."
            );
        }
        finally
        {
            resourceStream.Dispose();
        }
    }
}
