Editor/HotCodeEditor/CodeView/CodeView.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// HotCode's code editor: a find bar above a scroll area holding a <see cref="CodeCanvas"/>,
/// which draws the text, line numbers, selection and caret itself.
/// </summary>
public class CodeView : Widget
{
	public const float DefaultFontSize = 10;

	/// <summary>
	/// Shared by every editor; each one re-wraps on its next frame when this changes.
	/// </summary>
	public static bool WordWrap
	{
		get => HotCodeSettings.WordWrap;
		set => HotCodeSettings.WordWrap = value;
	}

	/// <summary>
	/// Code font size in points, shared by every editor (zoom). Each editor re-measures on its next paint.
	/// </summary>
	public static float FontSize
	{
		get => HotCodeSettings.FontSize;
		set => HotCodeSettings.FontSize = value;
	}

	private readonly ScrollArea _scroller;
	private readonly CodeCanvas _canvas;
	private readonly FindBar _findBar;

	private List<SearchMatch> _matches = new();
	private bool _batchingEdits;
	private int _currentMatch = -1;

	/// <summary>
	/// Called after every edit (typing, paste, undo...). Not called when <see cref="Text"/> is set.
	/// </summary>
	public Action TextChanged;

	/// <summary>
	/// F12 / Ctrl+Click / context menu, with the position to look up.
	/// </summary>
	public Action<TextPos> DefinitionRequested;

	/// <summary>
	/// Alt+Left (back = true) / Alt+Right.
	/// </summary>
	public Action<bool> NavigateRequested;

	/// <summary>
	/// The editor wants autocomplete suggestions for a position; answer with <see cref="ShowCompletions"/>.
	/// </summary>
	public Action<TextPos> CompletionRequested;

	/// <summary>
	/// The editor wants parameter hints for a position; answer with <see cref="ShowSignatureHelp"/>.
	/// </summary>
	public Action<TextPos> SignatureHelpRequested;

	private readonly Label _status;
	private double _statusHideTime;

	public CodeView( Widget parent, SyntaxLanguage language ) : base( parent )
	{
		Layout = Layout.Column();

		_findBar = Layout.Add( new FindBar( this ) );
		_findBar.Visible = false;

		_scroller = Layout.Add( new CodeScrollArea( this ), 1 );
		_canvas = new CodeCanvas( this, _scroller, language );
		_scroller.Canvas = _canvas;

		_status = Layout.Add( new Label( "", this ) );
		_status.SetStyles( $"background-color: {Theme.WidgetBackground.Hex}; padding: 3px 8px;" );
		_status.Visible = false;
	}

	/// <summary>
	/// Shows a short message under the editor for a few seconds.
	/// </summary>
	public void ShowMessage( string message, float seconds = 5 )
	{
		_status.Text = message;
		_status.Visible = true;
		_statusHideTime = RealTime.Now + seconds;
	}

	[EditorEvent.Frame]
	public void Frame()
	{
		if ( _status.Visible && RealTime.Now > _statusHideTime )
			_status.Visible = false;
	}

	public TextPos CaretPosition => _canvas.Caret;

	// Commands for the menu bar
	public void Undo() => _canvas.Undo();
	public void Redo() => _canvas.Redo();
	public void Cut() => _canvas.Cut();
	public void Copy() => _canvas.Copy();
	public void Paste() => _canvas.Paste();
	public void SelectAll() => _canvas.SelectAll();
	public void ToggleComment() => _canvas.ToggleComment();
	public void DuplicateLines() => _canvas.DuplicateLines();
	public void MoveLines( int direction ) => _canvas.MoveLines( direction );
	public void RequestDefinitionAtCaret() => RequestDefinition( _canvas.Caret );
	public void RequestCompletionAtCaret() => RequestCompletion( _canvas.Caret );
	public void RequestSignatureHelpAtCaret() => RequestSignatureHelp( _canvas.Caret );
	public bool CanUndo => _canvas.History.CanUndo;
	public bool CanRedo => _canvas.History.CanRedo;

	/// <summary>
	/// "Tabs" or "Spaces: 4", as detected from the file.
	/// </summary>
	public string IndentDescription => _canvas.IndentUnit == "\t" ? "Tabs" : $"Spaces: {_canvas.IndentUnit.Length}";

	public string LineEndingName => _canvas.Buffer.LineEnding == "\r\n" ? "CRLF" : "LF";
	public string LanguageName => _canvas.LanguageName;
	public int SelectionLength => _canvas.HasSelection ? _canvas.SelectedText.Length : 0;
	public int LineCount => _canvas.Buffer.LineCount;

	internal void RequestDefinition( TextPos pos ) => DefinitionRequested?.Invoke( pos );
	internal void RequestCompletion( TextPos pos ) => CompletionRequested?.Invoke( pos );

	public void ShowCompletions( CompletionResult result ) => _canvas.ShowCompletions( result );

	internal void RequestSignatureHelp( TextPos pos ) => SignatureHelpRequested?.Invoke( pos );
	public void ShowSignatureHelp( SignatureHelpResult result ) => _canvas.ShowSignatureHelp( result );
	internal void RequestNavigate( bool back ) => NavigateRequested?.Invoke( back );

	/// <summary>
	/// The whole file, using the line endings it was loaded with. Setting it clears undo history.
	/// </summary>
	public string Text
	{
		get => _canvas.Buffer.Text;
		set => _canvas.SetText( value );
	}

	/// <summary>
	/// The text differs from when it was loaded or last saved (undoing back counts as unmodified).
	/// </summary>
	public bool IsModified => _canvas.History.IsModified;

	public void MarkSaved() => _canvas.History.MarkSaved();

	/// <summary>
	/// Applies the trim-whitespace / final-newline settings as one undoable edit, keeping the caret where it is.
	/// </summary>
	public void FormatForSave( bool trimTrailingWhitespace, bool insertFinalNewline ) => _canvas.FormatForSave( trimTrailingWhitespace, insertFinalNewline );

	/// <summary>
	/// Replaces the text with what's on disk now, keeping the caret position. Clears undo history.
	/// </summary>
	public void ReloadText( string text ) => _canvas.ReloadText( text );

	public void FocusEditor() => _canvas.Focus( false );

	/// <summary>
	/// Moves the caret to a zero-based line and column and scrolls there.
	/// </summary>
	public void GoTo( int line, int column ) => _canvas.GoTo( new TextPos( line, column ) );

	/// <summary>
	/// Underlines compile errors and warnings.
	/// </summary>
	public void SetMarkers( IReadOnlyList<EditorMarker> markers ) => _canvas.SetMarkers( markers );

	internal void NotifyTextChanged()
	{
		if ( _batchingEdits ) return;
		if ( IsFindOpen ) RunSearch( keepPosition: true );
		TextChanged?.Invoke();
	}

	internal void OnScrollerResized() => _canvas.InvalidateLayout();

	// --- FIND / REPLACE ---

	public bool IsFindOpen => _findBar.Visible;

	public void OpenFind( bool replace )
	{
		// Seed the search with the selection, if it's a single line
		var selected = _canvas.SelectedText;
		if ( selected.Length > 0 && !selected.Contains( '\n' ) )
			_findBar.Query = selected;

		_findBar.ShowReplace = replace;
		_findBar.Visible = true;
		_findBar.FocusQuery();
		RunSearch( keepPosition: false );
	}

	public void CloseFind()
	{
		_findBar.Visible = false;
		_matches.Clear();
		_currentMatch = -1;
		_canvas.SetFindHighlights( null, -1 );
		FocusEditor();
	}

	/// <summary>
	/// Re-runs the search. With <paramref name="keepPosition"/> (after an edit) the current match
	/// stays near where it was; otherwise it jumps to the first match from the caret.
	/// </summary>
	internal void RunSearch( bool keepPosition )
	{
		var previous = _currentMatch >= 0 && _currentMatch < _matches.Count ? _matches[_currentMatch].Start : _canvas.SelStart;

		_matches = TextSearch.FindAll( _canvas.Buffer, _findBar.Options, out var error );
		_currentMatch = _matches.Count == 0 ? -1 : FirstMatchAtOrAfter( previous );

		if ( !keepPosition && _currentMatch >= 0 )
			SelectMatch( _currentMatch, focusEditor: false );

		_canvas.SetFindHighlights( _matches, _currentMatch );
		_findBar.SetStatus( _matches.Count, _currentMatch, error );
	}

	private int FirstMatchAtOrAfter( TextPos pos )
	{
		for ( int i = 0; i < _matches.Count; i++ )
			if ( _matches[i].Start >= pos ) return i;
		return 0;
	}

	public void FindNext( bool backwards )
	{
		if ( !IsFindOpen )
		{
			OpenFind( replace: false );
			return;
		}

		if ( _matches.Count == 0 ) return;

		var caret = _canvas.SelStart;
		int index;

		if ( backwards )
		{
			index = _matches.FindLastIndex( m => m.Start < caret );
			if ( index < 0 ) index = _matches.Count - 1;
		}
		else
		{
			// Skip the match that's already selected
			var from = _canvas.HasSelection ? _canvas.SelEnd : caret;
			index = _matches.FindIndex( m => m.Start >= from && !(m.Start == caret && m.End == _canvas.SelEnd && _canvas.HasSelection) );
			if ( index < 0 ) index = 0;
		}

		SelectMatch( index, focusEditor: false );
	}

	private void SelectMatch( int index, bool focusEditor )
	{
		_currentMatch = index;
		var m = _matches[index];
		_canvas.Select( m.Start, m.End );
		_canvas.CenterOnCaret();
		_canvas.SetFindHighlights( _matches, _currentMatch );
		_findBar.SetStatus( _matches.Count, _currentMatch, null );
		if ( focusEditor ) FocusEditor();
	}

	/// <summary>
	/// Replaces the current match (if it's selected) and moves to the next one.
	/// </summary>
	public void ReplaceCurrent()
	{
		if ( _matches.Count == 0 ) return;

		var current = _currentMatch >= 0 ? _matches[_currentMatch] : default;
		var isSelected = _currentMatch >= 0 && _canvas.HasSelection && _canvas.SelStart == current.Start && _canvas.SelEnd == current.End;

		// First press just selects the match, like other editors
		if ( !isSelected )
		{
			FindNext( backwards: false );
			return;
		}

		var replacement = TextSearch.ReplacementFor( current, _findBar.Replacement, _findBar.Options );
		_canvas.Replace( current.Start, current.End, replacement );

		// The edit re-ran the search; carry on from just after what we replaced
		var after = _canvas.Caret;
		if ( _matches.Count > 0 )
		{
			var next = _matches.FindIndex( m => m.Start >= after );
			SelectMatch( next < 0 ? 0 : next, focusEditor: false );
		}
	}

	public void ReplaceAll()
	{
		if ( _matches.Count == 0 ) return;

		var options = _findBar.Options;
		var replacement = _findBar.Replacement;
		var targets = _matches.ToList();

		// Back to front so earlier positions stay valid; one undo step for the lot
		_canvas.BeginTransaction();
		_batchingEdits = true;
		try
		{
			for ( int i = targets.Count - 1; i >= 0; i-- )
				_canvas.Replace( targets[i].Start, targets[i].End, TextSearch.ReplacementFor( targets[i], replacement, options ) );
		}
		finally
		{
			_batchingEdits = false;
			_canvas.EndTransaction();
		}

		NotifyTextChanged();

		Log.Info( $"[HotCode] Replaced {targets.Count} occurrence(s)" );
	}
}

/// <summary>
/// An underlined range in the editor, such as a compile error.
/// </summary>
public record EditorMarker( TextPos Start, TextPos End, bool IsError, string Message );

/// <summary>
/// Tells the editor when the visible area changes size, so word wrap can re-flow.
/// </summary>
internal class CodeScrollArea : ScrollArea
{
	private readonly CodeView _owner;

	public CodeScrollArea( CodeView owner ) : base( owner )
	{
		_owner = owner;
	}

	protected override void OnResize()
	{
		base.OnResize();
		_owner.OnScrollerResized();
	}
}

/// <summary>
/// Colours for the code area (the only part of HotCode that doesn't follow s&amp;box's theme).
/// Switched between palettes by <see cref="Use"/>, from the Code Colours setting.
/// </summary>
public static class SyntaxColors
{
	/// <summary>
	/// One code colour theme, as hex strings.
	/// </summary>
	private record Palette(
		string Background, string CurrentLine, string Selection, string Caret,
		string LineNumber, string LineNumberActive, string PopupBackground, string PopupBorder, string PopupSelection,
		string Text, string Keyword, string ControlKeyword, string Type, string Method,
		string String, string Number, string Comment, string Preprocessor, string Property );

	private static readonly Palette DarkPlus = new(
		"#1e1e1e", "#2a2d2e", "#264f78", "#aeafad",
		"#6e7681", "#cccccc", "#252526", "#454545", "#04395e",
		"#d4d4d4", "#569cd6", "#c586c0", "#4ec9b0", "#dcdcaa",
		"#ce9178", "#b5cea8", "#6a9955", "#9b9b9b", "#9cdcfe" );

	private static readonly Palette Monokai = new(
		"#272822", "#3e3d32", "#49483e", "#f8f8f0",
		"#90908a", "#f8f8f2", "#1e1f1c", "#414339", "#414339",
		"#f8f8f2", "#66d9ef", "#f92672", "#a6e22e", "#a6e22e",
		"#e6db74", "#ae81ff", "#88846f", "#f92672", "#fd971f" );

	private static readonly Palette OneDark = new(
		"#282c34", "#2c313c", "#3e4451", "#528bff",
		"#495162", "#abb2bf", "#21252b", "#181a1f", "#2c313a",
		"#abb2bf", "#c678dd", "#c678dd", "#e5c07b", "#61afef",
		"#98c379", "#d19a66", "#7f848e", "#c678dd", "#e06c75" );

	public static Color Background { get; private set; }
	public static Color Gutter => Background;
	public static Color CurrentLine { get; private set; }
	public static Color Selection { get; private set; }
	public static Color Caret { get; private set; }
	public static Color LineNumber { get; private set; }
	public static Color LineNumberActive { get; private set; }
	public static Color PopupBackground { get; private set; }
	public static Color PopupBorder { get; private set; }
	public static Color PopupSelection { get; private set; }

	public static Color Text { get; private set; }
	public static Color Keyword { get; private set; }
	public static Color ControlKeyword { get; private set; }
	public static Color Type { get; private set; }
	public static Color Method { get; private set; }
	public static Color String { get; private set; }
	public static Color Number { get; private set; }
	public static Color Comment { get; private set; }
	public static Color Preprocessor { get; private set; }
	public static Color Property { get; private set; }

	// The same in every theme
	public static readonly Color FindMatch = Hex( "#ea5c00" ).WithAlpha( 0.33f );
	public static readonly Color FindCurrent = Hex( "#9e6a03" ).WithAlpha( 0.6f );
	public static readonly Color Error = Hex( "#f14c4c" );
	public static readonly Color Warning = Hex( "#cca700" );
	public static readonly Color Link = Hex( "#4daafc" );

	static SyntaxColors() => Use( CodeTheme.DarkPlus );

	public static void Use( CodeTheme theme )
	{
		var p = theme switch
		{
			CodeTheme.Monokai => Monokai,
			CodeTheme.OneDark => OneDark,
			_ => DarkPlus
		};

		Background = Hex( p.Background );
		CurrentLine = Hex( p.CurrentLine );
		Selection = Hex( p.Selection );
		Caret = Hex( p.Caret );
		LineNumber = Hex( p.LineNumber );
		LineNumberActive = Hex( p.LineNumberActive );
		PopupBackground = Hex( p.PopupBackground );
		PopupBorder = Hex( p.PopupBorder );
		PopupSelection = Hex( p.PopupSelection );
		Text = Hex( p.Text );
		Keyword = Hex( p.Keyword );
		ControlKeyword = Hex( p.ControlKeyword );
		Type = Hex( p.Type );
		Method = Hex( p.Method );
		String = Hex( p.String );
		Number = Hex( p.Number );
		Comment = Hex( p.Comment );
		Preprocessor = Hex( p.Preprocessor );
		Property = Hex( p.Property );
	}

	public static Color For( TokenKind kind ) => kind switch
	{
		TokenKind.Keyword => Keyword,
		TokenKind.ControlKeyword => ControlKeyword,
		TokenKind.Type => Type,
		TokenKind.Method => Method,
		TokenKind.String => String,
		TokenKind.Number => Number,
		TokenKind.Comment => Comment,
		TokenKind.Preprocessor => Preprocessor,
		TokenKind.Property => Property,
		_ => Text
	};

	private static Color Hex( string hex ) => Color.Parse( hex ) ?? Color.White;
}