Editor/Infrastructure/TailBoxEditorWatcher.cs
using System;
using System.IO;
using System.Linq;
using Editor;
using static Sandbox.Internal.GlobalGameNamespace;
using static Sandbox.Internal.GlobalToolsNamespace;

namespace Sandbox.TailBox;

public sealed class TailBoxEditorWatcher
{
	private const string WatcherCookie = "tailwand.WatcherEnabled";
	private const double DebounceSeconds = 0.35;

	private static TailBoxEditorWatcher instance;

	private FileSystemWatcher watcher;
	private string watchedRoot;
	private string watchedOutputPath;
	private bool pendingGenerate;
	private DateTime lastChangeUtc;
	private DateTime lastProjectProbeUtc;
	private readonly object gate = new();

	public static bool WatcherEnabled
	{
		get => EditorCookie.Get( WatcherCookie, true );
		set => EditorCookie.Set( WatcherCookie, value );
	}

	public static TailBoxEditorWatcher EnsureStarted()
	{
		if ( instance is not null )
			return instance;

		instance = new TailBoxEditorWatcher();
		EditorEvent.Register( instance );
		instance.Reconfigure();
		return instance;
	}

	public static TailBoxGenerationResult GenerateNow( string projectRoot )
	{
		var result = TailBoxEditorProject.Generate( projectRoot );
		Log.Info( $"tailw& generated {result.GeneratedClassCount} utilities, skipped {result.SkippedClassCount}, warnings {result.Warnings.Count}: {result.OutputPath}" );

		foreach ( var skipped in result.Skipped.Take( 12 ) )
		{
			Log.Warning( $"tailw& skipped '{skipped.ClassName}' ({skipped.Reason}): {skipped.Detail}" );
		}

		return result;
	}

	public void Reconfigure()
	{
		var root = Project.Current?.GetRootPath();
		if ( string.IsNullOrWhiteSpace( root ) )
		{
			DisposeWatcher();
			return;
		}

		root = Path.GetFullPath( root );
		if ( !WatcherEnabled || !IsProjectOptedIn( root ) )
		{
			DisposeWatcher();
			watchedRoot = root;
			return;
		}

		var config = TailBoxEditorProject.LoadConfig( root );
		var outputPath = TailBoxEditorProject.ResolveOutputPath( root, config );

		if ( watcher is not null
			&& string.Equals( watchedRoot, root, StringComparison.OrdinalIgnoreCase )
			&& string.Equals( watchedOutputPath, outputPath, StringComparison.OrdinalIgnoreCase ) )
		{
			return;
		}

		DisposeWatcher();

		watchedRoot = root;
		watchedOutputPath = outputPath;
		watcher = new FileSystemWatcher( root )
		{
			IncludeSubdirectories = true,
			Filter = "*.*",
			NotifyFilter = NotifyFilters.LastWrite
				| NotifyFilters.FileName
				| NotifyFilters.DirectoryName
				| NotifyFilters.Size
				| NotifyFilters.CreationTime
		};

		watcher.Changed += OnFileChanged;
		watcher.Created += OnFileChanged;
		watcher.Deleted += OnFileChanged;
		watcher.Renamed += OnFileChanged;
		watcher.EnableRaisingEvents = true;
		Log.Info( $"tailw& watcher enabled for {root}" );
	}

	[EditorEvent.Frame]
	public void OnFrame()
	{
		if ( (DateTime.UtcNow - lastProjectProbeUtc).TotalSeconds > 1.0 )
		{
			lastProjectProbeUtc = DateTime.UtcNow;
			Reconfigure();
		}

		var shouldGenerate = false;
		lock ( gate )
		{
			if ( pendingGenerate && (DateTime.UtcNow - lastChangeUtc).TotalSeconds >= DebounceSeconds )
			{
				pendingGenerate = false;
				shouldGenerate = true;
			}
		}

		if ( !shouldGenerate || string.IsNullOrWhiteSpace( watchedRoot ) || !TailBoxEditorProject.ConfigExists( watchedRoot ) )
			return;

		try
		{
			GenerateNow( watchedRoot );
		}
		catch ( Exception ex )
		{
			Log.Error( $"tailw& generation failed: {ex.Message}" );
		}
	}

	private void OnFileChanged( object sender, FileSystemEventArgs e )
	{
		if ( !ShouldHandleChangedPath( watchedRoot, watchedOutputPath, e.FullPath ) )
			return;

		lock ( gate )
		{
			pendingGenerate = true;
			lastChangeUtc = DateTime.UtcNow;
		}
	}

	private void DisposeWatcher()
	{
		if ( watcher is null )
			return;

		watcher.EnableRaisingEvents = false;
		watcher.Changed -= OnFileChanged;
		watcher.Created -= OnFileChanged;
		watcher.Deleted -= OnFileChanged;
		watcher.Renamed -= OnFileChanged;
		watcher.Dispose();
		watcher = null;
	}

	public static bool IsProjectOptedIn( string projectRoot )
	{
		return !string.IsNullOrWhiteSpace( projectRoot ) && TailBoxEditorProject.ConfigExists( projectRoot );
	}

	public static bool ShouldHandleChangedPath( string projectRoot, string outputPath, string changedPath )
	{
		if ( string.IsNullOrWhiteSpace( projectRoot ) || string.IsNullOrWhiteSpace( changedPath ) )
			return false;

		var fullPath = Path.GetFullPath( changedPath );
		if ( !string.IsNullOrWhiteSpace( outputPath )
			&& string.Equals( fullPath, Path.GetFullPath( outputPath ), StringComparison.OrdinalIgnoreCase ) )
		{
			return false;
		}

		if ( TailBoxEditorProject.ShouldSkipPath( projectRoot, outputPath, fullPath ) )
			return false;

		if ( !TailBoxProjectFileSystem.TryGetRelativeProjectPath( projectRoot, fullPath, out var relative ) )
			return false;

		return string.Equals( relative, TailBoxConfig.FileName, StringComparison.OrdinalIgnoreCase )
			|| string.Equals( relative, TailBoxConfig.LegacyFileName, StringComparison.OrdinalIgnoreCase )
			|| string.Equals( Path.GetExtension( fullPath ), ".razor", StringComparison.OrdinalIgnoreCase );
	}
}