Editor/Bridge/DiagnosticsLog.cs
using System;
using System.IO;
using System.Text;

namespace Sandbox.SecBox.Bridge;

// File-backed diagnostic log at %LOCALAPPDATA%/secbox/secbox.log.
//
// Why: when secbox hangs or breaks the editor, the engine's in-memory log
// disappears with the editor process. A file persists across crashes and
// can be inspected after the fact. This logger also mirrors to the engine's
// Log when reachable, so live tailing works in normal operation.
//
// Hard invariants:
//   - Never throws. Logging failures are swallowed - the LAST thing we want
//     is the logger crashing inside an exception handler.
//   - Thread-safe via lock. We re-open the file each write so concurrent
//     processes (CLI + editor running side-by-side) don't corrupt it.
//   - Rotates at MaxBytes - current → .old, fresh file started.
public static class DiagnosticsLog
{
	const long MaxBytes = 4 * 1024 * 1024; // 4 MB rotation
	static readonly object _lock = new();
	static bool _firstWrite = true;

	public static string FilePath =>
		Path.Combine(
			Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
			"secbox", "secbox.log");

	public static void Trace(string message)                  => Write("TRACE", message, null);
	public static void Info(string message)                   => Write("INFO ", message, null);
	public static void Warn(string message)                   => Write("WARN ", message, null);
	public static void Error(string message)                  => Write("ERROR", message, null);
	public static void Error(string message, Exception ex)    => Write("ERROR", message, ex);
	public static void Fatal(string message, Exception ex)    => Write("FATAL", message, ex);

	// One-line trace + automatic exception capture if `func` throws. Returns
	// the exception if thrown (else null) so callers can re-throw if needed.
	public static Exception Wrap(string label, Action func)
	{
		Trace($"BEGIN {label}");
		try { func(); Trace($"END   {label}"); return null; }
		catch (Exception ex)
		{
			Error($"FAIL  {label}", ex);
			return ex;
		}
	}

	static void Write(string level, string message, Exception ex)
	{
		var line = BuildLine(level, message, ex);

		// File-only by design - secbox / sentinel emit hundreds of TRACE+INFO
		// lines per session (boot, ALC.Resolving, sentinel-log-route, runtime
		// finding routing, etc.) and mirroring them to the engine log floods
		// the editor console with content that's only useful when debugging
		// secbox itself. The full log persists at FilePath; tail that file
		// when you need live output.

		lock (_lock)
		{
			try
			{
				var path = FilePath;
				var dir = Path.GetDirectoryName(path);
				if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
					Directory.CreateDirectory(dir);

				RotateIfNeeded(path);

				if (_firstWrite)
				{
					File.AppendAllText(path,
						$"\n\n=== secbox session start {DateTime.UtcNow:O} pid={Environment.ProcessId} ===\n",
						Encoding.UTF8);
					_firstWrite = false;
				}

				File.AppendAllText(path, line + "\n", Encoding.UTF8);
			}
			catch { /* logger must not throw */ }
		}
	}

	static string BuildLine(string level, string message, Exception ex)
	{
		var sb = new StringBuilder();
		sb.Append(DateTime.UtcNow.ToString("HH:mm:ss.fff"));
		sb.Append(' ').Append(level).Append(' ');
		sb.Append('[').Append(Environment.CurrentManagedThreadId.ToString("D2")).Append("] ");
		sb.Append(message);
		if (ex != null)
		{
			sb.Append('\n').Append(ex.GetType().FullName).Append(": ").Append(ex.Message);
			if (!string.IsNullOrEmpty(ex.StackTrace))
				sb.Append('\n').Append(ex.StackTrace);
			var inner = ex.InnerException;
			while (inner != null)
			{
				sb.Append("\n--- inner: ").Append(inner.GetType().FullName).Append(": ").Append(inner.Message);
				if (!string.IsNullOrEmpty(inner.StackTrace)) sb.Append('\n').Append(inner.StackTrace);
				inner = inner.InnerException;
			}
		}
		return sb.ToString();
	}

	static void RotateIfNeeded(string path)
	{
		try
		{
			if (!File.Exists(path)) return;
			var info = new FileInfo(path);
			if (info.Length < MaxBytes) return;

			var oldPath = path + ".old";
			if (File.Exists(oldPath)) File.Delete(oldPath);
			File.Move(path, oldPath);
		}
		catch { /* ignore */ }
	}

	// Installs an AppDomain.UnhandledException handler so anything we miss
	// in our explicit try/catch still ends up on disk. Idempotent.
	//
	// FirstChanceException tracing is ONLY enabled when verbose=true. The
	// engine throws + catches many internal exceptions per second (asset
	// serialization probes, Cecil resolution attempts, etc.) - tracing all
	// of them floods the log and slows secbox itself.
	static bool _hookedUnhandled;
	static bool _hookedFirstChance;
	public static void InstallUnhandledHandler(bool verbose = false)
	{
		if (!_hookedUnhandled)
		{
			_hookedUnhandled = true;
			try
			{
				AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
				{
					Fatal("AppDomain.UnhandledException", args.ExceptionObject as Exception ?? new Exception(args.ExceptionObject?.ToString() ?? "(non-exception)"));
				};
			}
			catch { }
		}

		if (verbose && !_hookedFirstChance)
		{
			_hookedFirstChance = true;
			try
			{
				AppDomain.CurrentDomain.FirstChanceException += (sender, args) =>
				{
					var msg = args.Exception?.Message ?? "(null)";
					if (msg.Length > 200) msg = msg[..200] + "…";
					Trace($"first-chance: {args.Exception?.GetType().Name}: {msg}");
				};
			}
			catch { }
		}
	}
}