Editor/HaxeBox.cs
#nullable enable

using System;
using System.IO;
using System.Threading;
using System.Reflection;
using System.Collections.Generic;
using System.Text.Json;
using Editor;
using Sandbox;
using Sandbox.Diagnostics;

public static class HaxeBox {
    const string DefaultLibraryPath = "libraries/lemonsqueezy.haxebox";
    const string SettingsFileName = "haxebox.json";

    sealed class SettingsData {
        public bool HotloadEnabled { get; set; }
        public int BuildServerPort { get; set; } = 6060;
        public string HaxePath { get; set; } = "";
        public string SrcPath { get; set; } = "";
        public string OutPath { get; set; } = "";
        public string Libraries { get; set; } = "";
        public string Exclude { get; set; } = "Properties";
    }

    sealed class PreferencesPage : Widget {
        public PreferencesPage(Widget parent) : base(parent) {
            Layout = Layout.Column();
            Layout.Margin = 32;
            Layout.Spacing = 8;

            if (!EnsureInitialized()) {
                Layout.Add(new Label("Project is not loaded"));
                Layout.AddStretchCell();
                return;
            }

            Layout.Add(new Label.Subtitle("HaxeBox"));

            var draftPort = settings.BuildServerPort;
            var draftHaxePath = settings.HaxePath;
            var draftSrcPath = settings.SrcPath;
            var draftOutPath = settings.OutPath;
            var draftLibraries = settings.Libraries;
            var draftExclude = settings.Exclude;
            var draftHotload = settings.HotloadEnabled;
            Button.Primary? save = null;
            LineEdit? portEdit = null;
            Label? portError = null;

            var topRow = Layout.AddRow();
            topRow.Spacing = 8;

            topRow.Add(new Label("Build Server Port"));
            portEdit = topRow.Add(new LineEdit(settings.BuildServerPort.ToString()), 1);
            portEdit.PlaceholderText = "6060";
            portEdit.TextEdited += text => {
                if (!int.TryParse(text, out var parsed) || parsed < 1 || parsed > 65535) 
                {
                    UpdateSaveEnabled();
                    return;
                }
                draftPort = parsed;
                UpdateSaveEnabled();
            };
            portError = Layout.Add(new Label("Port must be a number from 1 to 65535"));
            portError.Visible = false;

            var hotload = topRow.Add(new Checkbox("Hotload"));
            hotload.State = settings.HotloadEnabled ? CheckState.On : CheckState.Off;
            hotload.StateChanged = state => {
                draftHotload = state == CheckState.On;
                UpdateSaveEnabled();
            };

            Layout.Add(new Label("Haxe Path (optional)"));
            var pathEdit = Layout.Add(new LineEdit(settings.HaxePath));
            pathEdit.TextEdited += text => {
                draftHaxePath = text.Trim();
                UpdateSaveEnabled();
            };

            Layout.Add(new Label("Libraries (separate with ';' or ',')"));
            var librariesEdit = Layout.Add(new LineEdit(settings.Libraries));
            librariesEdit.TextEdited += text => {
                draftLibraries = text.Trim();
                UpdateSaveEnabled();
            };

            Layout.AddSeparator();

            Layout.Add(new Label("Source path"));
            var srcEdit = Layout.Add(new LineEdit(settings.SrcPath));
            srcEdit.TextEdited += text => {
                draftSrcPath = text.Trim();
                UpdateSaveEnabled();
            };

            Layout.Add(new Label("Out path"));
            var outPath = Layout.Add(new LineEdit(settings.OutPath));
            outPath.TextEdited += text => {
                draftOutPath = text.Trim();
                UpdateSaveEnabled();
            };

            Layout.Add(new Label("Exclude (separate with ';' or ',')"));
            var excludeEdit = Layout.Add(new LineEdit(settings.Exclude));
            excludeEdit.TextEdited += text => {
                draftExclude = text.Trim();
                UpdateSaveEnabled();
            };

            Layout.AddSeparator();

            var buttonsRow = Layout.AddRow();
            buttonsRow.Spacing = 8;

            var gen = buttonsRow.Add(new Button("Generate Externs", "construction"));
            gen.Clicked = GenerateExterns;

            var clear = buttonsRow.Add(new Button("Clear Output", "delete"));
            clear.Clicked = ClearOutput;

            save = buttonsRow.Add(new Button.Primary("Save", "save"));
            save.Clicked = () => {
                if (portEdit == null || !int.TryParse(portEdit.Text, out var parsedPort) || parsedPort < 1 || parsedPort > 65535) {
                    logger.Warning("Invalid Haxe build server port. Allowed range: 1-65535.");
                    return;
                }

                draftPort = parsedPort;
                var prevHotload = settings.HotloadEnabled;
                var prevOutPath = settings.OutPath;

                settings.BuildServerPort = draftPort;
                settings.HaxePath = draftHaxePath;
                settings.SrcPath = draftSrcPath;
                settings.OutPath = draftOutPath;
                settings.Libraries = draftLibraries;
                settings.Exclude = draftExclude;
                settings.HotloadEnabled = draftHotload;

                SaveSettings();
                var prevOutNormalized = NormalizeConfiguredPath(prevOutPath, "code/__haxe__");
                var newOutNormalized = NormalizeConfiguredPath(settings.OutPath, "code/__haxe__");

                if (!string.Equals(prevOutNormalized, newOutNormalized, StringComparison.OrdinalIgnoreCase)) {
                    var oldOutAbs = ResolveProjectPath(prevOutNormalized);
                    var newOutAbs = ResolveProjectPath(newOutNormalized);
                    TryDeletePreviousOutput(oldOutAbs, newOutAbs);
                }

                ApplyBuilderSettings(true);

                if (settings.HotloadEnabled != prevHotload) {
                    if (settings.HotloadEnabled)
                        EnsureBuilder().Resume();
                    else
                        builder?.Pause();
                }

                UpdateSaveEnabled();
            };
            save.Enabled = false;

            UpdateSaveEnabled();
            Layout.AddStretchCell();

            void UpdateSaveEnabled() {
                var hasPortError = portEdit == null || !int.TryParse(portEdit.Text, out var parsedPort) || parsedPort < 1 || parsedPort > 65535;
                var hasChanges =
                    draftPort != settings.BuildServerPort ||
                    !string.Equals(draftHaxePath, settings.HaxePath, StringComparison.Ordinal) ||
                    !string.Equals(draftSrcPath, settings.SrcPath, StringComparison.Ordinal) ||
                    !string.Equals(draftOutPath, settings.OutPath, StringComparison.Ordinal) ||
                    !string.Equals(draftLibraries, settings.Libraries, StringComparison.Ordinal) ||
                    draftHotload != settings.HotloadEnabled ||
                    !string.Equals(draftExclude, settings.Exclude, StringComparison.Ordinal);

                if (save == null)
                    return;

                if (portError != null)
                    portError.Visible = hasPortError;

                save.Enabled = hasChanges && !hasPortError;
            }
        }
    }

    static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
    static SettingsData settings = new();
    static bool initialized;

    public static Logger logger = new Logger("HaxeBox");
    private static Builder? builder;
    
    public static string path = DefaultLibraryPath;
    public static string root = "";
    public static Project project = null!;
    public static (bool whitelist, bool release, HashSet<string> symbols) config = (true, true, []);

    public static string GetSrcPath() {
        return NormalizeConfiguredPath(settings.SrcPath, "code");
    }

    public static string GetOutPath() {
        return NormalizeConfiguredPath(settings.OutPath, "code/__haxe__");
    }
    
    public static string[] GetLibraries() {
        return ParseList(settings.Libraries);
    }
    
    public static string[] GetExclude() {
        return ParseList(settings.Exclude);
    }

    [Event("editor.preferences")]
    static void OnEditorPreferences(NavigationView container) {
        container.AddSectionHeader("HaxeBox");
        container.AddPage("HaxeBox", "construction", new PreferencesPage(container));
    }

    [Event("editor.created")]
    public static void OnCreated(EditorMainWindow mainWindow) {
        try {
            if (!EnsureInitialized())
                throw new InvalidOperationException("Project.Current is null");

            var externPath = Path.Combine(path, "haxe", "extern");
            if (!Directory.Exists(externPath) || Directory.GetFiles(externPath).Length == 0)
                GenerateExterns();

            if (settings.HotloadEnabled)
                EnsureBuilder().Resume();

            Editor.Application.OnWidgetClicked += OnWidgetClicked;
        } catch (Exception e) {
            logger.Error("Failed to start HaxeBox: " + e.ToString());
        }
    }

    [Event("app.exit")]
    private static void OnExit() {
        var activeBuilder = builder;
        builder = null;
        Editor.Application.OnWidgetClicked -= OnWidgetClicked;
        if (activeBuilder != null) {
            ThreadPool.QueueUserWorkItem(_ => {
                try {
                    activeBuilder.Dispose();
                } catch (Exception e) {
                    logger.Warning("Failed to dispose builder on exit: " + e.Message);
                }
            });
        }
    }

    [Event("scene.startplay")]
    private static void OnPlay() {
        if (!EnsureInitialized())
            return;

        var activeBuilder = EnsureBuilder();
        if (!activeBuilder.enabled)
            activeBuilder.BuildAsync();
    }
    
    [Event("compile.started")]
    private static void CompileStarted(CompileGroup group) {
        if (!EnsureInitialized())
            return;

        var cfg = project.Config.GetMetaOrDefault("Compiler", new Compiler.Configuration());
        var whitelist = !project.Config.IsStandaloneOnly;
        var release = cfg.ReleaseMode == Compiler.ReleaseMode.Release;
        var symbols = cfg.GetPreprocessorSymbols();

        if (whitelist != config.whitelist || release != config.release || !symbols.SetEquals(config.symbols)) {
            config = (whitelist, release, symbols);
            logger.Info("Build config changed");

            EnsureBuilder().Build();
        }
    }

    private static void GenerateExterns() {
        ThreadPool.QueueUserWorkItem(_ => {
            Toaster.CompileStarted("Haxe", "Generating extern types...");
            try {
                ExternGen.GenerateFromRuntime(["Sandbox"]);
                Toaster.CompileSucceeded("Haxe", "Extern types generated");
            } catch (Exception e) {
                Toaster.CompileFailed("Haxe", [e.Message], "Failed to generate extern types");
            }
        });
    }

    private static void ClearOutput() {
        try {
            var outPath = Path.Combine(root, GetOutPath());
            if (Directory.Exists(outPath))
                Directory.Delete(outPath, true);
            logger.Info("Cleared output");
        } catch (Exception e) {
            logger.Warning("Failed to clear output: " + e.ToString());
        }
    }

    private static bool EnsureInitialized() {
        if (initialized)
            return true;

        if (Project.Current == null)
            return false;

        project = Project.Current;
        root = project.GetRootPath() ?? root;
        path = FindPath();
        LoadSettings();

        initialized = true;
        return true;
    }

    private static Builder EnsureBuilder() {
        builder ??= new Builder(settings.BuildServerPort, GetHaxeCommand(), GetSrcPath(), GetOutPath(), GetExclude());
        builder.ApplySettings(settings.BuildServerPort, GetHaxeCommand(), GetSrcPath(), GetOutPath(), GetExclude());
        return builder;
    }

    private static void ApplyBuilderSettings(bool rebuild = false) {
        MainThread.Queue(() => {
            try {
                var activeBuilder = EnsureBuilder();
                activeBuilder.ApplySettings(settings.BuildServerPort, GetHaxeCommand(), GetSrcPath(), GetOutPath(), GetExclude());
                if (rebuild)
                    activeBuilder.BuildAsync();
            } catch (Exception e) {
                logger.Warning("Failed to apply builder settings: " + e.Message);
            }
        });
    }

    private static string GetSettingsPath() {
        if (string.IsNullOrEmpty(root))
            return "";

        return Path.Combine(root, SettingsFileName);
    }

    private static string GetHaxeCommand() {
        if (string.IsNullOrWhiteSpace(settings.HaxePath))
            return "haxe";

        return settings.HaxePath.Trim();
    }

    private static string NormalizeConfiguredPath(string? configured, string fallback) {
        var value = configured?.Trim() ?? "";
        if (string.IsNullOrWhiteSpace(value))
            value = fallback;

        return value.Replace('\\', '/').Trim('/');
    }

    private static string[] ParseList(string? value) {
        return (value ?? "").Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
    }

    private static string ResolveProjectPath(string configuredOrNormalizedPath) {
        if (Path.IsPathRooted(configuredOrNormalizedPath))
            return Path.GetFullPath(configuredOrNormalizedPath);

        return Path.GetFullPath(Path.Combine(root, configuredOrNormalizedPath));
    }

    private static void TryDeletePreviousOutput(string oldOutAbs, string newOutAbs) {
        try {
            if (string.Equals(oldOutAbs, newOutAbs, StringComparison.OrdinalIgnoreCase))
                return;
            if (Directory.Exists(oldOutAbs)) {
                Directory.Delete(oldOutAbs, true);
                logger.Info("Removed previous output: " + oldOutAbs);
            }
        } catch (Exception e) {
            logger.Warning("Failed to remove previous output: " + e.Message);
        }
    }

    private static void NormalizeSettings() {
        if (settings.BuildServerPort < 1 || settings.BuildServerPort > 65535)
            settings.BuildServerPort = 6060;

        settings.HaxePath = settings.HaxePath?.Trim() ?? "";
        settings.SrcPath = settings.SrcPath?.Trim() ?? "";
        settings.OutPath = settings.OutPath?.Trim() ?? "";
        settings.Libraries = settings.Libraries?.Trim() ?? "";
        settings.Exclude = settings.Exclude?.Trim() ?? "Properties";
    }

    private static void LoadSettings() {
        var settingsPath = GetSettingsPath();
        if (string.IsNullOrEmpty(settingsPath))
            return;

        try {
            if (File.Exists(settingsPath)) {
                var json = File.ReadAllText(settingsPath);
                var loaded = JsonSerializer.Deserialize<SettingsData>(json);
                if (loaded != null)
                    settings = loaded;
            }
        } catch (Exception e) {
            logger.Warning("Failed to load settings: " + e.Message);
        }

        NormalizeSettings();
    }

    private static void SaveSettings() {
        var settingsPath = GetSettingsPath();
        if (string.IsNullOrEmpty(settingsPath))
            return;

        try {
            NormalizeSettings();
            var json = JsonSerializer.Serialize(settings, JsonOptions);
            File.WriteAllText(settingsPath, json);
            logger.Info("Settings updated");
        } catch (Exception e) {
            logger.Warning("Failed to save settings: " + e.Message);
        }
    }

    static string FindPath() {
        var fs = Editor.FileSystem.Libraries;
        string? rel = null;
        foreach (var f in fs.FindFile("", "haxebox.sbproj", recursive: true)) {
            rel = f;
            break;
        }
        if (!string.IsNullOrEmpty(rel)) {
            var full = fs.GetFullPath(rel);
            if (!string.IsNullOrEmpty(full))
                return Path.GetDirectoryName(full)!;
        }
        return path;
    }

    static void OnWidgetClicked(Widget w, MouseEvent e) {
        if (w is not Button button) return;
        if (button.Text != "Next") return;

        var parent = w.Parent;
        if (parent == null) return;

        var parentType = parent.GetType();
        var parentFullName = parentType.FullName ?? "";
        if (parentFullName != "Editor.Wizards.StandaloneWizard" &&
            parentFullName != "Editor.Wizards.PublishWizard")
            return;

        // workaround https://github.com/Facepunch/sbox-public/issues/10037
        Editor.EditorEvent.Unregister(parent);

        var curProp = parentType.GetProperty("Current", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
        var current = curProp?.GetValue(parent);
        if (current == null) return;

        var currentFullName = current.GetType().FullName ?? "";
        if (currentFullName != "Editor.Wizards.StandaloneWizard+ReviewWizardPage" &&
            currentFullName != "Editor.Wizards.PublishWizard+ReviewWizardPage")
            return;

        var cb = button.Clicked;
        e.Accepted = true;

        EnsureBuilder().BuildAsync(res => {
            MainThread.Queue(cb);
            if (!res)
                logger.Warning("Failed to pre-build Release version. Debug version exported!");
        });
    }
}