Editor/HotCodeEditor/HotCodeEditor.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;

/// <summary>
/// The HotCode IDE window: file tree on the left, tabbed code editors on the right,
/// problems and output underneath. Saving a file inside a project s&amp;box has loaded
/// lets s&amp;box pick it up and hotload it.
/// </summary>
public class HotCodeEditor : Window
{
	private readonly HotCodeEntry _entry;
	private readonly string _root;
	private readonly TreeView _tree;
	private readonly Widget _explorer;
	private CodeFileNode _rootNode;
	private readonly EditorTabBar _tabs;
	private readonly Widget _editorStack;
	private readonly WelcomePage _welcome;

	// Status bar
	private readonly StatusItem _buildStatus;
	private readonly StatusItem _position;
	private readonly StatusItem _indent;
	private readonly StatusItem _lineEnding;
	private readonly StatusItem _encoding;
	private readonly StatusItem _language;
	private bool _projectLoaded;
	private double _nextLoadedCheck;
	private readonly OutputPanel _output;

	private readonly List<EditorDocument> _documents = new();
	private EditorDocument _active;

	// Tree nodes by folder path, so file system changes can refresh the right folder
	private readonly Dictionary<string, CodeFileNode> _nodes = new( StringComparer.OrdinalIgnoreCase );

	// Go to definition runs in the background; results come back through this queue
	private readonly ConcurrentQueue<(EditorDocument Doc, DefinitionResult Result)> _definitionResults = new();
	private bool _definitionPending;

	// Autocomplete and parameter hints run in the background, one at a time, newest request wins
	private readonly LatestOnlyWorker<CompletionResult> _completions = new();
	private readonly LatestOnlyWorker<SignatureHelpResult> _signatures = new();

	// Alt+Left / Alt+Right history of where we jumped from
	private record NavPoint( string Path, TextPos Pos );
	private readonly List<NavPoint> _navBack = new();
	private readonly List<NavPoint> _navForward = new();

	private readonly FileSystemWatcher _watcher;
	// (path, structural): structural changes (add/delete/rename) refresh the tree; edits only reload open files
	private readonly ConcurrentQueue<(string Path, bool Structural)> _changedPaths = new();

	public HotCodeEditor( HotCodeEntry entry )
	{
		_entry = entry;
		_root = Path.GetFullPath( entry.Directory );

		DeleteOnClose = true;
		SetWindowIcon( HotCodeLogo.WindowIcon );
		Size = new Vector2( 1200, 800 );
		MinimumSize = new Vector2( 600, 400 );

		Canvas = new Widget( this );
		Canvas.Layout = Layout.Column();

		// Top: tree | editors. Bottom: problems / output.
		var vertical = new Splitter( Canvas );
		vertical.IsVertical = true;
		Canvas.Layout.Add( vertical, 1 );

		var splitter = new Splitter( vertical );
		splitter.IsHorizontal = true;

		// LEFT: explorer header + file tree. Single click previews, double click opens for keeps.
		_explorer = new Widget( splitter );
		_explorer.Layout = Layout.Column();
		_explorer.Layout.Add( BuildExplorerHeader( _explorer, entry.Title ) );
		_explorer.Layout.AddSeparator( 1, Theme.Border );

		_tree = _explorer.Layout.Add( new TreeView( _explorer ), 1 );
		_tree.MinimumWidth = 200;
		_tree.ItemSelected += item => { if ( item is CodeFileNode { IsFolder: false } node ) OpenFile( node.FullPath, preview: true ); };
		_tree.ItemActivated += item => { if ( item is CodeFileNode { IsFolder: false } node ) OpenFile( node.FullPath, preview: false ); };

		_rootNode = new CodeFileNode( this, new DirectoryInfo( _root ) );
		_tree.AddItem( _rootNode );
		_tree.Open( _rootNode );

		// RIGHT: tabs + editors
		var right = new Widget( splitter );
		right.Layout = Layout.Column();

		_tabs = right.Layout.Add( new EditorTabBar( right ) );
		right.Layout.AddSeparator( 1, Theme.Border );
		_tabs.OnTabSelected = Activate;
		_tabs.OnTabCloseRequested = RequestClose;
		_tabs.OnTabPinned = doc => { doc.IsPreview = false; _tabs.Update(); };
		_tabs.GetTabTooltip = doc => Path.GetRelativePath( _root, doc.Path );

		_editorStack = right.Layout.Add( new Widget( right ), 1 );
		_editorStack.Layout = Layout.Column();

		_welcome = _editorStack.Layout.Add( new WelcomePage( _editorStack, entry.Title, _root, path => OpenFile( path, preview: false ) ), 1 );
		_welcome.SetRecentFiles( RecentFiles() );

		splitter.AddWidget( _explorer );
		splitter.AddWidget( right );
		splitter.SetStretch( 0, 1 );
		splitter.SetStretch( 1, 4 );

		// BOTTOM: problems / output
		_output = new OutputPanel( vertical, _root );
		_output.ProblemActivated = GoToProblem;
		_output.ProblemsChanged = _ => ApplyMarkers();

		vertical.AddWidget( splitter );
		vertical.AddWidget( _output );
		vertical.SetStretch( 0, 4 );
		vertical.SetStretch( 1, 1 );

		// Status bar: build state on the left, facts about the open file on the right
		if ( !StatusBar.IsValid() ) StatusBar = new StatusBar( this );

		var flame = new LogoWidget( StatusBar, 14, badge: false ) { ToolTip = "HotCode" };
		StatusBar.AddWidgetLeft( flame, 0 );

		_buildStatus = new StatusItem( StatusBar, "Click to show problems" ) { Clicked = _output.ShowProblems };
		StatusBar.AddWidgetLeft( _buildStatus, 0 );

		_position = new StatusItem( StatusBar, "Go to line (Ctrl+G)" ) { Clicked = GoToLine };
		_indent = new StatusItem( StatusBar, "Indentation, detected from the file" );
		_lineEnding = new StatusItem( StatusBar, "Line endings, kept as they were when saving" );
		_encoding = new StatusItem( StatusBar, "Encoding" );
		_language = new StatusItem( StatusBar, "Language" );

		foreach ( var item in new[] { _position, _indent, _lineEnding, _encoding, _language } )
		{
			if ( item != _position ) StatusBar.AddWidgetRight( new StatusDivider( StatusBar ), 0 );
			StatusBar.AddWidgetRight( item, 0 );
		}

		BuildMenuBar();

		// Keep the tree (and unmodified open files) in sync with changes made outside HotCode
		try
		{
			_watcher = new FileSystemWatcher( _root )
			{
				IncludeSubdirectories = true,
				NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
			};
			_watcher.Created += ( _, e ) => QueueChange( e.FullPath, structural: true );
			_watcher.Deleted += ( _, e ) => QueueChange( e.FullPath, structural: true );
			_watcher.Changed += ( _, e ) => QueueChange( e.FullPath, structural: false );
			_watcher.Renamed += ( _, e ) => { QueueChange( e.OldFullPath, structural: true ); QueueChange( e.FullPath, structural: true ); };
			_watcher.EnableRaisingEvents = true;
		}
		catch ( Exception e ) when ( e is IOException or ArgumentException or UnauthorizedAccessException )
		{
			Log.Warning( $"[HotCode] Can't watch {_root} for changes: {e.Message}" );
		}

		UpdateTitle();
		Show();
	}

	public override void OnDestroyed()
	{
		_watcher?.Dispose();
		base.OnDestroyed();
	}

	// --- OPENING / SWITCHING ---

	private EditorDocument FindDocument( string path ) => _documents.FirstOrDefault( d => string.Equals( d.Path, path, StringComparison.OrdinalIgnoreCase ) );

	private EditorDocument OpenFile( string path, bool preview )
	{
		var existing = FindDocument( path );
		if ( existing is not null )
		{
			if ( !preview ) existing.IsPreview = false;
			Activate( existing );
			return existing;
		}

		if ( !TryReadFile( path, out var contents, out var hasBom ) )
			return null;

		var editor = new CodeView( _editorStack, SyntaxLanguage.FromPath( path ) );
		editor.Text = contents;
		editor.Visible = false;
		_editorStack.Layout.Add( editor, 1 );

		var doc = new EditorDocument( path, editor ) { IsPreview = preview, HasBom = hasBom };
		editor.TextChanged += () => OnEdited( doc );
		editor.DefinitionRequested = pos => GoToDefinition( doc, pos );
		editor.NavigateRequested = Navigate;
		editor.CompletionRequested = pos => RequestCompletion( doc, pos );
		editor.SignatureHelpRequested = pos => RequestSignatureHelp( doc, pos );

		// A new preview takes the place of the old one, if the old one wasn't touched
		var oldPreview = preview ? _documents.FirstOrDefault( d => d.IsPreview && !d.IsDirty ) : null;
		if ( oldPreview is not null )
		{
			var index = _documents.IndexOf( oldPreview );
			RemoveDocument( oldPreview );
			_documents.Insert( index, doc );
			_tabs.Insert( index, doc );
		}
		else
		{
			_documents.Add( doc );
			_tabs.Add( doc );
		}

		ApplyMarkers( doc );
		Activate( doc );
		return doc;
	}

	private static bool TryReadFile( string path, out string contents, out bool hasBom )
	{
		contents = null;
		hasBom = false;

		try
		{
			var bytes = File.ReadAllBytes( path );
			hasBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
			contents = new UTF8Encoding( false ).GetString( bytes, hasBom ? 3 : 0, bytes.Length - (hasBom ? 3 : 0) );
			return true;
		}
		catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
		{
			Log.Warning( $"[HotCode] Couldn't open {path}: {e.Message}" );
			return false;
		}
	}

	private void Activate( EditorDocument doc )
	{
		if ( _active is not null && _active != doc && _active.IsDirty && HotCodeSettings.AutoSave == AutoSaveMode.WhenSwitchingTabs )
			SaveDocument( _active );

		if ( _active is not null && _active != doc )
			_active.Editor.Visible = false;

		_active = doc;
		_welcome.Visible = doc is null;

		if ( doc is not null )
		{
			doc.Editor.Visible = true;
			doc.Editor.FocusEditor();
			if ( !doc.IsPreview ) AddRecentFile( doc.Path );
		}
		else
		{
			_welcome.SetRecentFiles( RecentFiles() );
		}

		_tabs.SetActive( doc );
		_tree.Update();
		UpdateTitle();
	}

	private void OnEdited( EditorDocument doc )
	{
		// IsModified goes back to false when edits are undone back to the saved text
		var dirty = doc.Editor.IsModified;
		var changed = dirty != doc.IsDirty || (dirty && doc.IsPreview);
		doc.LastEdit = RealTime.Now;
		doc.IsDirty = dirty;
		if ( dirty ) doc.IsPreview = false;

		if ( changed )
		{
			_tabs.Update();
			_tree.Update();
			UpdateTitle();
		}
	}

	// --- PROBLEMS ---

	private void GoToProblem( ProblemItem item )
	{
		PushNavPoint();
		var doc = OpenFile( item.Path, preview: false );
		doc?.Editor.GoTo( item.Line, item.Column );
	}

	private void ApplyMarkers()
	{
		foreach ( var doc in _documents )
			ApplyMarkers( doc );
	}

	/// <summary>
	/// Underlines this file's compile errors and warnings.
	/// </summary>
	private void ApplyMarkers( EditorDocument doc )
	{
		var markers = _output.Problems
			.Where( p => string.Equals( Path.GetFullPath( p.Path ), Path.GetFullPath( doc.Path ), StringComparison.OrdinalIgnoreCase ) )
			.Select( p => new EditorMarker( new TextPos( p.Line, p.Column ), new TextPos( p.EndLine, p.EndColumn ), p.Severity == DiagnosticSeverity.Error, $"{p.Id}: {p.Message}" ) )
			.ToList();

		doc.Editor.SetMarkers( markers );
	}

	// --- GO TO DEFINITION ---

	private void GoToDefinition( EditorDocument doc, TextPos pos )
	{
		if ( !doc.Path.EndsWith( ".cs", StringComparison.OrdinalIgnoreCase ) )
		{
			doc.Editor.ShowMessage( "Go to Definition only works in C# files." );
			return;
		}

		if ( _definitionPending ) return;

		// Read s&box's compiler state here on the main thread; the slow Roslyn work happens in the background
		RoslynProject.Snapshot snapshot;
		try
		{
			snapshot = RoslynProject.TakeSnapshot( doc.Path, _root, _documents.Select( d => (d.Path, d.Editor.Text) ) );
		}
		catch ( Exception e )
		{
			doc.Editor.ShowMessage( $"Couldn't look that up: {e.Message}" );
			return;
		}

		_definitionPending = true;
		doc.Editor.ShowMessage( "Finding definition...", 60 );

		Task.Run( () => DefinitionFinder.Find( snapshot, pos.Line, pos.Col ) )
			.ContinueWith( t => _definitionResults.Enqueue( (doc, t.IsFaulted ? DefinitionResult.Fail( t.Exception?.InnerException?.Message ) : t.Result) ) );
	}

	// --- AUTOCOMPLETE ---

	/// <summary>
	/// What Roslyn needs to know about the code right now. Read on the main thread, used in the background.
	/// </summary>
	private RoslynProject.Snapshot TakeSnapshot( EditorDocument doc )
	{
		if ( !doc.Path.EndsWith( ".cs", StringComparison.OrdinalIgnoreCase ) ) return null;

		try
		{
			return RoslynProject.TakeSnapshot( doc.Path, _root, _documents.Select( d => (d.Path, d.Editor.Text) ) );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[HotCode] Couldn't read the project for code help: {e.Message}" );
			return null;
		}
	}

	private void RequestCompletion( EditorDocument doc, TextPos pos )
	{
		var snapshot = TakeSnapshot( doc );
		if ( snapshot is null ) return;

		_completions.Request( () => CompletionProvider.Get( snapshot, pos.Line, pos.Col ), result =>
		{
			if ( result.Error is not null ) Log.Warning( $"[HotCode] Autocomplete failed: {result.Error}" );
			else if ( _documents.Contains( doc ) ) doc.Editor.ShowCompletions( result );
		} );
	}

	// --- PARAMETER HINTS ---

	private void RequestSignatureHelp( EditorDocument doc, TextPos pos )
	{
		var snapshot = TakeSnapshot( doc );
		if ( snapshot is null ) return;

		_signatures.Request( () => SignatureHelpProvider.Get( snapshot, pos.Line, pos.Col ), result =>
		{
			if ( _documents.Contains( doc ) ) doc.Editor.ShowSignatureHelp( result );
		} );
	}

	private void ShowDefinitionResult( EditorDocument doc, DefinitionResult result )
	{
		_definitionPending = false;
		var open = _documents.Contains( doc );

		if ( result.Error is not null )
		{
			if ( open ) doc.Editor.ShowMessage( result.Error );
			return;
		}

		if ( result.Locations.Count == 0 )
		{
			if ( open ) doc.Editor.ShowMessage( $"'{result.Symbol}' is defined in {result.Assembly ?? "a compiled library"}, which has no source here.", 6 );
			return;
		}

		if ( open ) doc.Editor.ShowMessage( "", 0 );

		if ( result.Locations.Count == 1 )
		{
			JumpTo( result.Locations[0] );
			return;
		}

		// Several (partial classes): let the user pick
		var menu = new ContextMenu( this );
		foreach ( var location in result.Locations )
		{
			var label = $"{Path.GetRelativePath( _root, location.Path )}:{location.Line + 1}    {location.Preview}";
			menu.AddOption( label, "code", () => JumpTo( location ) );
		}
		menu.OpenAtCursor();
	}

	private void JumpTo( DefinitionLocation location )
	{
		PushNavPoint();
		var target = OpenFile( location.Path, preview: false );
		target?.Editor.GoTo( location.Line, location.Column );
	}

	private void PushNavPoint()
	{
		if ( _active is null ) return;

		_navBack.Add( new NavPoint( _active.Path, _active.Editor.CaretPosition ) );
		if ( _navBack.Count > 100 ) _navBack.RemoveAt( 0 );
		_navForward.Clear();
	}

	/// <summary>
	/// Alt+Left / Alt+Right: back to where we jumped from, and forward again.
	/// </summary>
	private void Navigate( bool back )
	{
		var from = back ? _navBack : _navForward;
		var to = back ? _navForward : _navBack;

		// Skip spots in files that have since been deleted
		while ( from.Count > 0 && !File.Exists( from[^1].Path ) ) from.RemoveAt( from.Count - 1 );
		if ( from.Count == 0 ) return;

		var target = from[^1];
		from.RemoveAt( from.Count - 1 );
		if ( _active is not null ) to.Add( new NavPoint( _active.Path, _active.Editor.CaretPosition ) );

		var doc = OpenFile( target.Path, preview: false );
		doc?.Editor.GoTo( target.Pos.Line, target.Pos.Col );
	}

	[Shortcut( "hotcode.toggle-output", "CTRL+J" )]
	public void ToggleOutput() => _output.Visible = !_output.Visible;

	// --- FILE TREE ---

	internal FileState StateOf( string path )
	{
		var doc = FindDocument( path );
		return doc is null ? FileState.Closed : doc == _active ? FileState.Active : FileState.Open;
	}

	internal bool IsUnsaved( string path ) => FindDocument( path )?.IsDirty == true;

	// --- MENU BAR ---

	/// <summary>
	/// File / Edit / View / Go / Project / Help. Shortcuts are shown after a tab ("Save\tCtrl+S"), which
	/// Qt displays right-aligned without registering a second key binding next to the ones the editor handles.
	/// Menus that show on/off state rebuild each time they open.
	/// </summary>
	private void BuildMenuBar()
	{
		MenuBar.Clear();

		var file = MenuBar.AddMenu( "File" );
		file.AddOption( "New File in Code Folder...", "note_add", () => NewFileInCodeFolder( component: false ) );
		file.AddOption( "New C# Component...", "code", () => NewFileInCodeFolder( component: true ) );
		file.AddOption( "Open File...", "file_open", OpenFileDialog );
		file.AddSeparator();
		file.AddOption( "Save\tCtrl+S", "save", Save );
		file.AddOption( "Save All\tCtrl+Shift+S", "save_as", SaveAll );
		file.AddSeparator();
		file.AddOption( "Close Tab\tCtrl+W", "close", CloseActiveTab );
		file.AddOption( "Close Other Tabs", "tab_unselected", CloseOtherTabs );
		file.AddOption( "Close All Tabs", "clear_all", CloseAllTabs );
		file.AddSeparator();
		file.AddOption( "Reveal Current File in Explorer", "folder_open", () => { if ( _active is not null ) EditorUtility.OpenFileFolder( _active.Path ); } );
		file.AddOption( "Copy Current File Path", "content_copy", () => { if ( _active is not null ) EditorUtility.Clipboard.Copy( _active.Path ); } );
		file.AddSeparator();
		file.AddOption( "Settings...\tCtrl+,", "settings", HotCodeSettingsWindow.Open );
		file.AddOption( "HotCode Launcher", HotCodeLogo.IconName, () => new HotCodeLauncher() );
		file.AddOption( "Close Window", "logout", () => Close() );

		var edit = MenuBar.AddMenu( "Edit" );
		edit.AddOption( "Undo\tCtrl+Z", "undo", () => _active?.Editor.Undo() );
		edit.AddOption( "Redo\tCtrl+Y", "redo", () => _active?.Editor.Redo() );
		edit.AddSeparator();
		edit.AddOption( "Cut\tCtrl+X", "content_cut", () => _active?.Editor.Cut() );
		edit.AddOption( "Copy\tCtrl+C", "content_copy", () => _active?.Editor.Copy() );
		edit.AddOption( "Paste\tCtrl+V", "content_paste", () => _active?.Editor.Paste() );
		edit.AddOption( "Select All\tCtrl+A", "select_all", () => _active?.Editor.SelectAll() );
		edit.AddSeparator();
		edit.AddOption( "Find\tCtrl+F", "search", () => _active?.Editor.OpenFind( replace: false ) );
		edit.AddOption( "Replace\tCtrl+H", "find_replace", () => _active?.Editor.OpenFind( replace: true ) );
		edit.AddSeparator();
		edit.AddOption( "Toggle Comment\tCtrl+/", "comment", () => _active?.Editor.ToggleComment() );
		edit.AddOption( "Duplicate Line\tCtrl+D", "content_copy", () => _active?.Editor.DuplicateLines() );
		edit.AddOption( "Move Line Up\tAlt+Up", "arrow_upward", () => _active?.Editor.MoveLines( -1 ) );
		edit.AddOption( "Move Line Down\tAlt+Down", "arrow_downward", () => _active?.Editor.MoveLines( 1 ) );
		edit.AddSeparator();
		edit.AddOption( "Trigger Suggestions\tCtrl+Space", "auto_awesome", () => _active?.Editor.RequestCompletionAtCaret() );
		edit.AddOption( "Parameter Hints\tCtrl+Shift+Space", "info", () => _active?.Editor.RequestSignatureHelpAtCaret() );

		var view = MenuBar.AddMenu( "View" );
		view.AboutToShow += () => FillViewMenu( view );

		var go = MenuBar.AddMenu( "Go" );
		go.AddOption( "Go to Definition\tF12", "start", () => _active?.Editor.RequestDefinitionAtCaret() );
		go.AddOption( "Go to Line...\tCtrl+G", "format_list_numbered", GoToLine );
		go.AddSeparator();
		go.AddOption( "Back\tAlt+Left", "arrow_back", () => Navigate( back: true ) );
		go.AddOption( "Forward\tAlt+Right", "arrow_forward", () => Navigate( back: false ) );
		go.AddSeparator();
		go.AddOption( "Next Problem\tF8", "error", NextProblem );
		go.AddOption( "Previous Problem\tShift+F8", "error_outline", PreviousProblem );

		var project = MenuBar.AddMenu( "Project" );
		project.AboutToShow += () => FillProjectMenu( project );

		var help = MenuBar.AddMenu( "Help" );
		help.AddOption( "Welcome Page / Shortcuts", "home", ShowWelcomePage );
		help.AddOption( "Settings...", "settings", HotCodeSettingsWindow.Open );
		help.AddOption( "About HotCode", HotCodeLogo.IconName, () =>
			new PopupWindow( "About HotCode", "HotCode - a code editor built into s&box.\n\nRoslyn-powered autocomplete, parameter hints and go to definition, with live compile errors from s&box.", "Close", new Dictionary<string, Action>() ).Show() );
	}

	private void FillProjectMenu( Menu project )
	{
		project.Clear();
		project.AddOption( "Open Project Folder", "folder_open", () => EditorUtility.OpenFolder( _root ) );
		project.AddOption( "Open in External Code Editor", "launch", () => CodeEditor.OpenSolution() );
		project.AddOption( "Generate Solution", "account_tree", () =>
		{
			EditorUtility.Projects.GenerateSolution();
			Log.Info( "[HotCode] Generated solution files" );
		} );
		project.AddSeparator();
		project.AddOption( "Refresh File Tree", "refresh", () => _rootNode.Dirty() );
		project.AddSeparator();

		HotCodeInstallUi.AddMenuOptions( project, _entry, null );
		project.AddOption( "Add HotCode to Another Project...", HotCodeLogo.IconName, () =>
		{
			var fd = new FileDialog( null );
			fd.Title = "Add HotCode to Project";
			fd.Directory = Path.GetDirectoryName( _root );
			fd.SetFindExistingFile();
			fd.SetModeOpen();
			fd.SetNameFilter( "s&box Project (*.sbproj)" );
			if ( fd.Execute() ) HotCodeInstallUi.AskInstall( HotCodeEntry.FromPath( fd.SelectedFile ) );
		} );
	}

	private void FillViewMenu( Menu view )
	{
		view.Clear();

		AddToggle( view, "File Tree", "account_tree", _explorer.Visible, on => _explorer.Visible = on );
		AddToggle( view, "Bottom Panel\tCtrl+J", "dock_to_bottom", _output.Visible, on => _output.Visible = on );
		AddToggle( view, "Status Bar", "power_input", StatusBar.Visible, on => StatusBar.Visible = on );
		view.AddSeparator();
		AddToggle( view, "Word Wrap\tAlt+Z", "wrap_text", CodeView.WordWrap, on => CodeView.WordWrap = on );
		view.AddSeparator();
		view.AddOption( "Zoom In\tCtrl+=", "zoom_in", () => CodeView.FontSize += 1 );
		view.AddOption( "Zoom Out\tCtrl+-", "zoom_out", () => CodeView.FontSize -= 1 );
		view.AddOption( $"Reset Zoom ({CodeView.FontSize:0}pt)", "youtube_searched_for", () => CodeView.FontSize = CodeView.DefaultFontSize );
		view.AddSeparator();
		view.AddOption( "Problems", "error_outline", _output.ShowProblems );
		view.AddOption( "Output", "terminal", _output.ShowOutput );
	}

	private static void AddToggle( Menu menu, string text, string icon, bool on, Action<bool> set )
	{
		var option = menu.AddOption( text, icon );
		option.Checkable = true;
		option.Checked = on;
		option.Toggled += set;
	}

	/// <summary>
	/// New files from the menu go in the project's Code folder (or the project root if there isn't one).
	/// </summary>
	private void NewFileInCodeFolder( bool component )
	{
		var code = Path.Combine( _root, "Code" );
		var folder = Directory.Exists( code ) ? code : _root;

		if ( component )
			AskCreate( folder, "New Component", "Component name:", "MyComponent", name => ProjectFiles.CreateComponent( folder, name ) );
		else
			AskCreate( folder, "New File", "File name:", "NewFile.cs", name => ProjectFiles.CreateFile( folder, name ) );
	}

	private void OpenFileDialog()
	{
		var fd = new FileDialog( null );
		fd.Title = "Open File";
		fd.Directory = _active is not null ? Path.GetDirectoryName( _active.Path ) : _root;
		fd.SetFindExistingFile();
		fd.SetModeOpen();
		if ( fd.Execute() ) OpenFile( fd.SelectedFile, preview: false );
	}

	[Shortcut( "hotcode.settings", "CTRL+," )]
	public void OpenSettings() => HotCodeSettingsWindow.Open();

	[Shortcut( "hotcode.save-all", "CTRL+SHIFT+S" )]
	public void SaveAll()
	{
		foreach ( var doc in _documents.Where( d => d.IsDirty ).ToList() )
			SaveDocument( doc );
	}

	private void CloseOtherTabs()
	{
		foreach ( var doc in _documents.Where( d => d != _active ).ToList() )
			RequestClose( doc );
	}

	private void CloseAllTabs()
	{
		foreach ( var doc in _documents.ToList() )
			RequestClose( doc );
	}

	/// <summary>
	/// Shows the welcome page without closing anything; clicking a tab goes back to it.
	/// </summary>
	private void ShowWelcomePage()
	{
		if ( _active is not null ) _active.Editor.Visible = false;
		_active = null;
		_welcome.SetRecentFiles( RecentFiles() );
		_welcome.Visible = true;
		_tabs.SetActive( null );
		_tree.Update();
	}

	[Shortcut( "hotcode.next-problem", "F8" )]
	public void NextProblem() => StepProblem( 1 );

	[Shortcut( "hotcode.previous-problem", "SHIFT+F8" )]
	public void PreviousProblem() => StepProblem( -1 );

	private int _problemIndex = -1;

	private void StepProblem( int direction )
	{
		var problems = _output.Problems;
		if ( problems.Count == 0 ) return;

		_problemIndex = ((_problemIndex + direction) % problems.Count + problems.Count) % problems.Count;
		GoToProblem( problems[_problemIndex] );
	}

	// --- EXPLORER HEADER ---

	private Widget BuildExplorerHeader( Widget parent, string title )
	{
		var header = new Widget( parent );
		header.FixedHeight = HotCodeStyle.TabHeight;
		header.OnPaintOverride = () =>
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.TabBarBackground );
			Paint.DrawRect( header.LocalRect );

			Paint.SetDefaultFont( 7, 700 );
			Paint.SetPen( Theme.TextLight );
			Paint.DrawText( new Rect( 10, 0, header.Width - 120, header.Height ), Paint.GetElidedText( $"EXPLORER  -  {title.ToUpperInvariant()}", header.Width - 130, ElideMode.Right ), TextFlag.LeftCenter );
			return false;
		};

		header.Layout = Layout.Row();
		header.Layout.Margin = new Sandbox.UI.Margin( 0, 0, 4, 0 );
		header.Layout.AddStretchCell();

		var rootFolder = Directory.Exists( Path.Combine( _root, "Code" ) ) ? Path.Combine( _root, "Code" ) : _root;
		header.Layout.Add( new IconButton( "note_add", () => NewFileInCodeFolder( component: false ) ) { ToolTip = "New File" } );
		header.Layout.Add( new IconButton( "create_new_folder", () => AskCreate( rootFolder, "New Folder", "Folder name:", "NewFolder", name => ProjectFiles.CreateFolder( rootFolder, name ) ) ) { ToolTip = "New Folder" } );
		header.Layout.Add( new IconButton( "refresh", () => _rootNode.Dirty() ) { ToolTip = "Refresh" } );
		header.Layout.Add( new IconButton( "unfold_less", () =>
		{
			_tree.Close( _rootNode, recursive: true );
			_tree.Open( _rootNode );
		} ) { ToolTip = "Collapse All" } );

		return header;
	}

	// --- RECENT FILES ---

	private string RecentFilesCookie => "hotcode.recentfiles." + _root.ToLowerInvariant().Replace( '\\', '/' );

	private List<string> RecentFiles() => EditorCookie.Get<List<string>>( RecentFilesCookie, null ) ?? new();

	private void AddRecentFile( string path )
	{
		var files = RecentFiles();
		files.RemoveAll( f => string.Equals( f, path, StringComparison.OrdinalIgnoreCase ) );
		files.Insert( 0, path );
		if ( files.Count > 20 ) files.RemoveRange( 20, files.Count - 20 );
		EditorCookie.Set( RecentFilesCookie, files );
	}

	// --- GO TO LINE ---

	/// <summary>
	/// Ctrl+G: "42" or "42:7" (line:column).
	/// </summary>
	[Shortcut( "hotcode.goto-line", "CTRL+G" )]
	public void GoToLine()
	{
		var doc = _active;
		if ( doc is null ) return;

		var current = doc.Editor.CaretPosition;
		Dialog.AskString( text =>
		{
			var parts = text.Split( ':', ',' );
			if ( !int.TryParse( parts[0].Trim(), out var line ) ) return;
			var column = parts.Length > 1 && int.TryParse( parts[1].Trim(), out var c ) ? c : 1;

			PushNavPoint();
			doc.Editor.GoTo( Math.Clamp( line, 1, doc.Editor.LineCount ) - 1, Math.Max( 1, column ) - 1 );
			doc.Editor.FocusEditor();
		}, $"Line number (1 - {doc.Editor.LineCount}), or line:column", okay: "Go", initialName: (current.Line + 1).ToString(), title: "Go to Line" );
	}

	// --- AUTO SAVE ---

	/// <summary>
	/// With Auto Save "after a short pause", saves files once you've stopped typing for a moment.
	/// Saving is what triggers s&amp;box's hotload, so the game keeps up with your edits.
	/// </summary>
	private void AutoSaveIfIdle()
	{
		if ( HotCodeSettings.AutoSave != AutoSaveMode.AfterDelay ) return;

		foreach ( var doc in _documents )
		{
			if ( doc.IsDirty && RealTime.Now - doc.LastEdit > HotCodeSettings.AutoSaveDelay && File.Exists( doc.Path ) )
				SaveDocument( doc );
		}
	}

	// --- STATUS BAR ---

	private void UpdateStatusBar()
	{
		// Is s&box compiling this project? If not, there's no live build to report on.
		if ( RealTime.Now > _nextLoadedCheck )
		{
			_nextLoadedCheck = RealTime.Now + 2;
			_projectLoaded = EditorUtility.Projects.GetAll().Any( p =>
			{
				var root = p.GetRootPath();
				return !string.IsNullOrEmpty( root ) && (ProjectFiles.IsUnder( _root, root ) || ProjectFiles.IsUnder( root, _root ));
			} );
		}

		if ( !_projectLoaded )
			_buildStatus.Set( "Not loaded in s&box - no live compile", "cloud_off", Theme.TextLight );
		else if ( _output.IsBuilding )
			_buildStatus.Set( "Building...", "sync", Theme.Primary );
		else if ( _output.ErrorCount > 0 )
			_buildStatus.Set( $"{_output.ErrorCount} error{(_output.ErrorCount == 1 ? "" : "s")}, {_output.WarningCount} warning{(_output.WarningCount == 1 ? "" : "s")}", "error", Theme.Red );
		else if ( _output.WarningCount > 0 )
			_buildStatus.Set( $"Build OK, {_output.WarningCount} warning{(_output.WarningCount == 1 ? "" : "s")}", "warning", Theme.Yellow );
		else if ( _output.LastBuildSucceeded == true )
			_buildStatus.Set( "Build succeeded", "check_circle", Theme.Green );
		else
			_buildStatus.Set( "Ready", "check", Theme.TextLight );

		var doc = _active;
		if ( doc is null )
		{
			foreach ( var item in new[] { _position, _indent, _lineEnding, _encoding, _language } )
				item.Set( "" );
			return;
		}

		var caret = doc.Editor.CaretPosition;
		var selected = doc.Editor.SelectionLength;
		_position.Set( $"Ln {caret.Line + 1}, Col {caret.Col + 1}" + (selected > 0 ? $" ({selected} selected)" : "") );
		_indent.Set( doc.Editor.IndentDescription );
		_lineEnding.Set( doc.Editor.LineEndingName );
		_encoding.Set( doc.HasBom ? "UTF-8 with BOM" : "UTF-8" );
		_language.Set( doc.Editor.LanguageName, HotCodeStyle.FileIcon( doc.Path ), HotCodeStyle.FileIconColor( doc.Path ) );
	}

	internal void RegisterNode( CodeFileNode node )
	{
		if ( node.IsFolder ) _nodes[Normalize( node.FullPath )] = node;
	}

	private static string Normalize( string path ) => Path.GetFullPath( path ).TrimEnd( '\\', '/' );

	/// <summary>
	/// Rebuilds a folder's children in the tree (and opens it, so new items are visible).
	/// </summary>
	private void RefreshFolder( string folder, bool open = false )
	{
		if ( !_nodes.TryGetValue( Normalize( folder ), out var node ) ) return;

		node.Dirty();
		if ( open ) _tree.Open( node );
	}

	internal void ShowFileContextMenu( CodeFileNode node )
	{
		var path = node.FullPath;
		var folder = node.IsFolder ? path : Path.GetDirectoryName( path );
		var isRoot = string.Equals( Normalize( path ), Normalize( _root ), StringComparison.OrdinalIgnoreCase );

		var menu = new ContextMenu( this );

		if ( !node.IsFolder )
		{
			menu.AddOption( "Open", "open_in_new", () => OpenFile( path, preview: false ) );
			menu.AddOption( "Open in External Editor", "launch", () => CodeEditor.OpenFile( path ) );
			menu.AddSeparator();
		}

		menu.AddOption( "New File...", "note_add", () => AskCreate( folder, "New File", "File name:", "NewFile.cs", name => ProjectFiles.CreateFile( folder, name ) ) );
		menu.AddOption( "New C# Component...", "code", () => AskCreate( folder, "New Component", "Component name:", "MyComponent", name => ProjectFiles.CreateComponent( folder, name ) ) );
		menu.AddOption( "New Folder...", "create_new_folder", () => AskCreate( folder, "New Folder", "Folder name:", "NewFolder", name => ProjectFiles.CreateFolder( folder, name ) ) );
		menu.AddSeparator();

		if ( !isRoot )
		{
			menu.AddOption( "Rename...", "drive_file_rename_outline", () => AskRename( node ) );
			menu.AddOption( "Delete", "delete", () => AskDelete( node ) );
			menu.AddSeparator();
		}

		menu.AddOption( "Copy Path", "content_copy", () => EditorUtility.Clipboard.Copy( path ) );
		menu.AddOption( "Copy Relative Path", "content_copy", () => EditorUtility.Clipboard.Copy( Path.GetRelativePath( _root, path ) ) );
		menu.AddOption( "Reveal in File Explorer", "folder_open", () => EditorUtility.OpenFileFolder( path ) );

		if ( node.IsFolder )
			menu.AddOption( "Refresh", "refresh", () => node.Dirty() );

		menu.OpenAtCursor();
	}

	private void AskCreate( string folder, string title, string question, string suggestion, Func<string, string> create )
	{
		Dialog.AskString( name =>
		{
			if ( !TryFileOp( () => create( name ), out var created ) ) return;

			RefreshFolder( folder, open: true );
			if ( File.Exists( created ) ) OpenFile( created, preview: false );
		}, question, okay: "Create", initialName: suggestion, title: title );
	}

	private void AskRename( CodeFileNode node )
	{
		var oldPath = node.FullPath;

		Dialog.AskString( name =>
		{
			if ( !TryFileOp( () => ProjectFiles.Rename( oldPath, name ), out var newPath ) ) return;

			// Open tabs follow the file (or everything inside a renamed folder)
			foreach ( var doc in _documents )
			{
				if ( string.Equals( doc.Path, oldPath, StringComparison.OrdinalIgnoreCase ) )
					doc.Path = newPath;
				else if ( ProjectFiles.IsUnder( doc.Path, oldPath ) )
					doc.Path = newPath + doc.Path[oldPath.Length..];
			}

			_tabs.Update();
			RefreshFolder( Path.GetDirectoryName( oldPath ) );
		}, "New name:", okay: "Rename", initialName: node.Name, title: "Rename" );
	}

	private void AskDelete( CodeFileNode node )
	{
		var path = node.FullPath;
		var affected = _documents.Where( d => ProjectFiles.IsUnder( d.Path, path ) ).ToList();
		var warning = affected.Any( d => d.IsDirty ) ? " Unsaved changes in open files will be lost." : "";

		Dialog.AskConfirm( () =>
		{
			if ( !TryFileOp( () => { ProjectFiles.Delete( path ); return path; }, out _ ) ) return;

			foreach ( var doc in affected ) CloseDocument( doc );
			RefreshFolder( Path.GetDirectoryName( path ) );
		}, $"Move '{node.Name}' to the Recycle Bin?{warning}", "Delete", "Delete", "Cancel" );
	}

	/// <summary>
	/// Runs a file operation and shows any error instead of throwing.
	/// </summary>
	private bool TryFileOp( Func<string> op, out string result )
	{
		try
		{
			result = op();
			return true;
		}
		catch ( Exception e ) when ( e is InvalidOperationException or IOException or UnauthorizedAccessException or OperationCanceledException )
		{
			result = null;
			Log.Warning( $"[HotCode] {e.Message}" );
			new PopupWindow( "HotCode", e.Message, "OK", new Dictionary<string, Action>() ).Show();
			return false;
		}
	}

	// --- WATCHING THE DISK ---

	private void QueueChange( string path, bool structural )
	{
		// Build output churns constantly and is never shown; ignore it
		if ( CodeFileNode.IsInHiddenFolder( Path.GetRelativePath( _root, path ) ) ) return;
		_changedPaths.Enqueue( (path, structural) );
	}

	[EditorEvent.Frame]
	public void Frame()
	{
		while ( _definitionResults.TryDequeue( out var definition ) )
			ShowDefinitionResult( definition.Doc, definition.Result );

		_completions.Pump();
		_signatures.Pump();
		UpdateStatusBar();
		AutoSaveIfIdle();

		if ( _changedPaths.IsEmpty ) return;

		var folders = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var files = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		while ( _changedPaths.TryDequeue( out var change ) )
		{
			files.Add( change.Path );
			var parent = Path.GetDirectoryName( change.Path );
			if ( change.Structural && parent is not null ) folders.Add( parent );
		}

		foreach ( var folder in folders )
			RefreshFolder( folder );

		foreach ( var file in files )
			ReloadIfChangedOnDisk( file );
	}

	/// <summary>
	/// Picks up edits made by other programs to a file that's open but has no unsaved changes here.
	/// </summary>
	private void ReloadIfChangedOnDisk( string path )
	{
		var doc = FindDocument( path );
		if ( doc is null || doc.IsDirty || !File.Exists( path ) || !HotCodeSettings.ReloadChangedFiles ) return;
		if ( !TryReadFile( path, out var contents, out _ ) ) return;

		// Our own saves land here too; only reload when the text really differs
		if ( TextBuffer.Normalize( contents ) == TextBuffer.Normalize( doc.Editor.Text ) ) return;

		doc.Editor.ReloadText( contents );
		ApplyMarkers( doc );
		Log.Info( $"[HotCode] Reloaded {doc.Title} (changed outside HotCode)" );
	}

	// --- CLOSING ---

	[Shortcut( "hotcode.close-tab", "CTRL+W" )]
	public void CloseActiveTab()
	{
		if ( _active is not null )
			RequestClose( _active );
	}

	private void RequestClose( EditorDocument doc )
	{
		if ( !doc.IsDirty )
		{
			CloseDocument( doc );
			return;
		}

		var confirm = new PopupWindow(
			"Unsaved Changes", $"Save changes to {doc.Title}?", "Cancel",
			new Dictionary<string, Action>()
			{
				{ "Don't Save", () => CloseDocument( doc ) },
				{ "Save", () => { if ( SaveDocument( doc ) ) CloseDocument( doc ); } }
			}
		);

		confirm.Show();
	}

	private void CloseDocument( EditorDocument doc )
	{
		var index = _documents.IndexOf( doc );
		if ( index < 0 ) return;
		var wasActive = doc == _active;

		RemoveDocument( doc );

		if ( wasActive )
		{
			// Same as most IDEs: move to the tab that slid into this spot, or the one before it
			var next = _documents.Count == 0 ? null : _documents[Math.Min( index, _documents.Count - 1 )];
			_active = null;
			Activate( next );
		}

		UpdateTitle();
	}

	/// <summary>
	/// Drops a document without any prompts or activating anything else.
	/// </summary>
	private void RemoveDocument( EditorDocument doc )
	{
		_documents.Remove( doc );
		_tabs.Remove( doc );
		doc.Editor.Destroy();

		if ( _active == doc )
			_active = null;
	}

	// --- SAVING ---

	[Shortcut( "editor.save", "CTRL+S" )]
	public void Save()
	{
		if ( _active is not null )
			SaveDocument( _active );
	}

	private bool SaveDocument( EditorDocument doc )
	{
		if ( !doc.IsDirty )
			return true;

		if ( HotCodeSettings.TrimTrailingWhitespace || HotCodeSettings.InsertFinalNewline )
			doc.Editor.FormatForSave( HotCodeSettings.TrimTrailingWhitespace, HotCodeSettings.InsertFinalNewline );

		try
		{
			File.WriteAllText( doc.Path, doc.Editor.Text, new UTF8Encoding( doc.HasBom ) );
			Log.Info( $"[HotCode] Saved {doc.Title}" );
		}
		catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
		{
			Log.Warning( $"[HotCode] Couldn't save {doc.Path}: {e.Message}" );
			return false;
		}

		doc.Editor.MarkSaved();
		doc.IsDirty = false;
		_tabs.Update();
		_tree.Update();
		UpdateTitle();
		return true;
	}

	private void UpdateTitle()
	{
		var unsaved = _documents.Count( d => d.IsDirty );

		WindowTitle = unsaved > 0
			? $"HotCode - {_entry.Title} ({unsaved} unsaved)"
			: $"HotCode - {_entry.Title}";
	}

	protected override bool OnClose()
	{
		var dirty = _documents.Where( d => d.IsDirty ).ToList();
		if ( dirty.Count == 0 )
			return true;

		var confirm = new PopupWindow(
			"Unsaved Changes", $"{dirty.Count} file(s) have unsaved changes. Save before closing?", "Cancel",
			new Dictionary<string, Action>()
			{
				{ "Don't Save", () => { dirty.ForEach( d => d.IsDirty = false ); Close(); } },
				{ "Save", () => { if ( dirty.All( SaveDocument ) ) Close(); } }
			}
		);

		confirm.Show();
		return false;
	}
}