Editor/Toaster.cs
#nullable enable

using System;
using System.Linq;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Editor;
using Sandbox;
using Sandbox.UI;

public static class Toaster {
    static CompileNotice? current;

	public static void CompileStarted(string name = "Haxe", string? subtitle = null) {
		MainThread.Queue(() => {
			current = GetOrCreate(Key(name));
			current.SetCompiling(name, subtitle);
		});
	}

	public static void CompileProgress(string name, string subtitle) {
		MainThread.Queue(() => {
			current = GetOrCreate(Key(name));
			current.SetProgress(subtitle);
		});
	}

	public static void CompileSucceeded(string name = "Haxe", string? subtitle = null, float removeAfterSeconds = 1.0f) {
		MainThread.Queue(() => {
			current = GetOrCreate(Key(name));
			current.SetSuccess(name, subtitle);
			RemoveCurrent(removeAfterSeconds);

			if (EditorPreferences.NotificationSounds)
				EditorUtility.PlayRawSound("sounds/editor/success.wav");
		});
	}

	public static void CompileFailed(
		string name = "Haxe",
		IEnumerable<HaxeDiag>? diagnostics = null,
		string? subtitle = null,
		float? removeAfterSeconds = null) {
		MainThread.Queue(() => {
			current = GetOrCreate(Key(name));
			var timeout = removeAfterSeconds ?? EditorPreferences.ErrorNotificationTimeout;

			current.SetFailed(name, subtitle, diagnostics);

			RemoveCurrent(timeout);

			if (EditorPreferences.NotificationSounds)
				EditorUtility.PlayRawSound("sounds/editor/fail.wav");
                
			if (diagnostics != null) {
				foreach (var d in diagnostics) {
					if (d.FilePath != null) {
                        var path = d.FilePath.Replace('\\', '/');
						HaxeBox.logger.Error($"{path}({d.Line},{d.Column}): error: {d.Message}");
					} else
						HaxeBox.logger.Error(d.Message);
				}
			}
		});
	}

	public static void CompileFailed(
		string name,
		IEnumerable<string> rawLines,
		string? subtitle = null,
		float? removeAfterSeconds = null,
		HaxeDiagSeverity severity = HaxeDiagSeverity.Error) {
		var diags = rawLines.Select(l => HaxeDiagParse.FromLine(l, severity));
		CompileFailed(name, diags, subtitle, removeAfterSeconds);
	}

    public static void RemoveCurrent(float after = 1.0f) {
        if (current != null)
            ToastManager.Remove(current, after);
    }

    private static string? ResolveForCodeEditor(string? path) {
        if (string.IsNullOrWhiteSpace(path))
            return null;

        path = path.Replace('\\', '/');

        if (path.Length >= 3 && char.IsLetter(path[0]) && path[1] == ':' && (path[2] == '/' || path[2] == '\\'))
            return path.Replace('/', System.IO.Path.DirectorySeparatorChar);

        if (path.StartsWith("/"))
            return path;

        var combined = System.IO.Path.Combine(HaxeBox.root, path);
        if (System.IO.File.Exists(combined))
            return combined;
            
        return path;
    }

	public enum HaxeDiagSeverity {
		Info,
		Warning,
		Error
	}

	public readonly record struct HaxeDiag(
		HaxeDiagSeverity Severity,
		string Message,
		string? FilePath = null,
		int Line = 0,
		int Column = 0
	);

	public static class HaxeDiagParse {
		// C:\proj\Main.hx:123: characters 5-12 : Unknown identifier foo
		private static readonly Regex RxCharacters =
			new(@"^(?<file>.+?):(?<line>\d+):\s*characters\s*(?<c1>\d+)-(?<c2>\d+)\s*:\s*(?<msg>.+)$",
				RegexOptions.Compiled);

		// /path/Main.hx:123:45: Something bad
		private static readonly Regex RxLineCol =
			new(@"^(?<file>.+?):(?<line>\d+):(?<col>\d+):\s*(?<msg>.+)$",
				RegexOptions.Compiled);

		// /path/Main.hx:123: Something bad (no col)
		private static readonly Regex RxLineOnly =
			new(@"^(?<file>.+?):(?<line>\d+):\s*(?<msg>.+)$",
				RegexOptions.Compiled);

		public static HaxeDiag FromLine(string line, HaxeDiagSeverity severity = HaxeDiagSeverity.Error) {
			if (string.IsNullOrWhiteSpace(line))
				return new HaxeDiag(severity, "");

			var m1 = RxCharacters.Match(line);
			if (m1.Success) {
				return new HaxeDiag(
					severity,
					m1.Groups["msg"].Value.Trim(),
					m1.Groups["file"].Value.Trim(),
					SafeInt(m1.Groups["line"].Value),
					SafeInt(m1.Groups["c1"].Value) + 1
				);
			}

			var m2 = RxLineCol.Match(line);
			if (m2.Success) {
				return new HaxeDiag(
					severity,
					m2.Groups["msg"].Value.Trim(),
					m2.Groups["file"].Value.Trim(),
					SafeInt(m2.Groups["line"].Value),
					SafeInt(m2.Groups["col"].Value)
				);
			}

			var m3 = RxLineOnly.Match(line);
			if (m3.Success) {
				return new HaxeDiag(
					severity,
					m3.Groups["msg"].Value.Trim(),
					m3.Groups["file"].Value.Trim(),
					SafeInt(m3.Groups["line"].Value),
					1
				);
			}

			return new HaxeDiag(severity, line.Trim());
		}

		private static int SafeInt(string s) {
			return int.TryParse(s, out var v) ? v : 0;
		}
	}

	// internals -----------------------------

	private static string Key(string name) => $"haxe_compile::{name}";

	private static CompileNotice GetOrCreate(string key) {
		var toast = ToastManager.All.OfType<CompileNotice>().FirstOrDefault(x => x.Key == key);
		if (toast is null)
			toast = new CompileNotice(key);
		return toast;
	}

	private sealed class CompileNotice : ToastWidget {
		public string Key { get; }

		private bool _isErrored;

		// used for Shift+Click behavior (open all files)
		private readonly List<(string file, int line, int col)> _jumpTargets = new();

		public CompileNotice(string key) {
			Key = key;

			Icon = "build_circle";
			Position = 10;

			FixedWidth = 300;
			FixedHeight = 76;

			Reset();
		}

		protected override Vector2 SizeHint() => 1000;

		public override bool WantsVisible {
			get {
				if (EditorPreferences.CompileNotifications == EditorPreferences.NotificationLevel.ShowAlways)
					return true;

				if (EditorPreferences.CompileNotifications == EditorPreferences.NotificationLevel.ShowOnError)
					return _isErrored;

				return false;
			}
		}

		public override void Reset() {
			base.Reset();

			_isErrored = false;
			IsRunning = false;

			BorderColor = Theme.Primary;
			SetBodyWidget(null);

			FixedWidth = 300;
			FixedHeight = 76;

			_jumpTargets.Clear();
			ToolTip = null;
		}

		protected override void OnPaint() {
			if (!EditorPreferences.NotificationPopups) return;
			base.OnPaint();
		}

		protected override void OnMousePress(MouseEvent e) {
			base.OnMousePress(e);

			if (e.LeftMouseButton && e.HasShift) {
				foreach (var t in _jumpTargets)
					if (!string.IsNullOrWhiteSpace(t.file))
						CodeEditor.OpenFile(t.file, t.line, t.col);

				e.Accepted = true;
			}
		}

		public void SetCompiling(string name, string? subtitle) {
			Reset();

			Icon = "build_circle";
			Title = name;
			Subtitle = subtitle ?? "Compiling…";
			BorderColor = Theme.Primary;

			IsRunning = true;
		}

		public void SetProgress(string subtitle) {
			Subtitle = subtitle;
		}

		public void SetSuccess(string name, string? subtitle) {
			Reset();

			Icon = "check_circle";
			Title = name;
			Subtitle = subtitle ?? "Succeeded";
			BorderColor = Theme.Green;

			_isErrored = false;
			IsRunning = false;
		}

		public void SetFailed(string name, string? subtitle, IEnumerable<HaxeDiag>? diagnostics) {
			Reset();

			Icon = "error";
			Title = name;
			Subtitle = subtitle ?? "";
			BorderColor = Theme.Red;

			_isErrored = true;
			IsRunning = false;

			AddDiagnostics(diagnostics);
		}

		private void AddDiagnostics(IEnumerable<HaxeDiag>? diagnostics) {
			if (diagnostics == null) return;

			// up to 5
			var lines = diagnostics
				.Where(d => !string.IsNullOrWhiteSpace(d.Message))
				.Take(5)
				.ToArray();

			if (lines.Length == 0) return;

			// expand
			FixedWidth = 700;
			FixedHeight = 160;

			var body = new Widget(this) { Layout = Layout.Column() };

			foreach (var d in lines)
			{
				var w = body.Layout.Add(new DiagnosticLineWidget(d));

				if (d.FilePath != null && d.Line > 0)
					_jumpTargets.Add((d.FilePath, d.Line, Math.Max(1, d.Column)));
			}

			SetBodyWidget(body);

			// add tooltip
			ToolTip = string.Join("\n", lines.Select(d =>
				d.FilePath != null
					? $"{System.IO.Path.GetFileName(d.FilePath)}:{d.Line}:{Math.Max(1, d.Column)}  {d.Message}"
					: d.Message));
		}
	}

	private sealed class DiagnosticLineWidget : Widget {
		private readonly HaxeDiag _diag;

		public DiagnosticLineWidget(HaxeDiag diag) : base(null) {
			_diag = diag;

			FixedHeight = 18;
			MinimumWidth = 400;

			if (_diag.FilePath != null && _diag.Line > 0) {
				Cursor = CursorShape.Finger;
				ToolTip = $"{_diag.Message}\n{_diag.FilePath}:{_diag.Line}:{Math.Max(1, _diag.Column)}";
			} else {
				Cursor = CursorShape.Arrow;
				ToolTip = _diag.Message;
			}
		}

		protected override void OnPaint() {
			base.OnPaint();

			Paint.Antialiasing = true;
			Paint.TextAntialiasing = true;

			var rect = LocalRect;

			var severityColor = _diag.Severity switch {
				HaxeDiagSeverity.Warning => Theme.Yellow,
				HaxeDiagSeverity.Info => Theme.Primary,
				_ => Theme.Red
			};

			var textColor = Color.Lerp(Color.White, severityColor, 0.5f);

			if (IsUnderMouse && _diag.FilePath != null) {
				Paint.ClearPen();
				Paint.SetBrush(severityColor.WithAlpha(0.1f));
				Paint.DrawRect(rect);
			}

			rect = rect.Shrink(4, 0);

			// file:line
			string rhs = "";
			if (_diag.FilePath != null && _diag.Line > 0)
				rhs = $"{System.IO.Path.GetFileName(_diag.FilePath)}:{_diag.Line}";

			Paint.SetPen(textColor.WithAlpha(0.8f));
			var rhsRect = Paint.DrawText(rect, rhs, TextFlag.RightCenter);

			rect.Right -= rhsRect.Width;
			rect.Right -= 8;

			// message
			Paint.SetPen(textColor.WithAlpha(0.6f));
			Paint.DrawText(rect.Shrink(16, 0, 0, 0), _diag.Message, TextFlag.LeftCenter);

			// left dot indicator
			Paint.ClearPen();
			Paint.SetBrush(severityColor);
			Paint.DrawCircle(new Rect(0, 16).Shrink(2));
		}

		protected override void OnMouseClick(MouseEvent e) {
			if (e.LeftMouseButton && _diag.FilePath != null && _diag.Line > 0) {
				var p = ResolveForCodeEditor(_diag.FilePath);
                if (p != null)
                    CodeEditor.OpenFile(p, _diag.Line, Math.Max(1, _diag.Column - 1));
				e.Accepted = true;
				return;
			}
			base.OnMouseClick(e);
		}
	}
}