Code/InteractiveComputer/Apps/PaneExplorerApp.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using PaneOS.InteractiveComputer.Core;
using Sandbox;
using Sandbox.UI;

namespace PaneOS.InteractiveComputer.Apps;

[ComputerApp( "system.paneexplorer", "Pane Explorer", Icon = "PE", SortOrder = 18 )]
public sealed class PaneExplorerApp : IComputerApp
{
	public ComputerAppSession Run( ComputerAppContext context )
	{
		return new ComputerAppSession
		{
			Title = "Pane Explorer",
			Icon = "PE",
			Content = new PaneExplorerPanel( context )
		};
	}
}

[StyleSheet( "InteractiveComputerApps.scss" )]
public sealed class PaneExplorerPanel : ComputerWarmupPanel
{
	private readonly ComputerAppContext context;
	private readonly string archivePath;
	private Panel listHost = null!;
	private Panel contextMenuHost = null!;
	private Label pathLabel = null!;
	private readonly IReadOnlyList<string> documentsPath;
	private IReadOnlyList<string> currentPath;
	private string? selectedPath;
	private string? contextMenuPath;
	private readonly Stack<string[]> backHistory = new();
	private readonly Stack<string[]> forwardHistory = new();
	private readonly Dictionary<string, Panel> rowByPath = new( StringComparer.OrdinalIgnoreCase );
	private static readonly IReadOnlyList<string> RecycleBinPath = new[] { "C:", "Recycle Bin" };

	public PaneExplorerPanel( ComputerAppContext context )
	{
		this.context = context;
		AddClass( "explorer-app" );

		archivePath = context.GetArchivePath();
		PaneArchiveFileSystem.EnsureArchive(
			archivePath,
			context.Computer.ResolvePersistentArchiveUserName( context.Runtime.State ),
			context.Runtime.Apps );

		documentsPath = ParsePath( context.GetDefaultDocumentsPath() );
		currentPath = ParsePath( context.LoadValue( "path" ) ?? context.GetDefaultDocumentsPath() );
		BuildUi();
	}

	protected override void WarmupRefresh()
	{
		BuildUi();
	}

	private void BuildUi()
	{
		DeleteChildren( true );

		pathLabel = new Label { Parent = this };
		pathLabel.AddClass( "explorer-path" );

		var toolbar = new Panel { Parent = this };
		toolbar.AddClass( "explorer-toolbar" );

		CreateToolbarButton( toolbar, "<", NavigateBack, "explorer-button-small" );
		CreateToolbarButton( toolbar, ">", NavigateForward, "explorer-button-small" );
		if ( !IsAtRootPath() )
			CreateToolbarButton( toolbar, "My PC", () => NavigateTo( Array.Empty<string>() ), "explorer-button-wide" );
		if ( !IsAtDocumentsPath() )
			CreateToolbarButton( toolbar, "My Documents", () => NavigateTo( documentsPath ), "explorer-button-xl" );
		if ( !IsAtRecycleBinPath() )
			CreateToolbarButton( toolbar, "Recycle Bin", () => NavigateTo( RecycleBinPath ), "explorer-button-xl" );
		CreateToolbarButton( toolbar, "Rename", PromptRenameSelected );
		if ( IsAtRecycleBinPath() )
			CreateToolbarButton( toolbar, "Restore", RestoreSelected );

		var table = new Panel { Parent = this };
		table.AddClass( "explorer-table" );

		var header = new Panel { Parent = table };
		header.AddClass( "explorer-row explorer-header" );
		AddCell( header, "Name", "explorer-cell explorer-name-column", true );
		AddCell( header, "Type", "explorer-cell explorer-type-column", true );
		AddCell( header, "Size", "explorer-cell explorer-size-column", true );

		listHost = new ExplorerListPanel( ShowFolderContextMenu, ClearSelectionAndHideContextMenu ) { Parent = table };
		listHost.AddClass( "explorer-list" );

		contextMenuHost = new Panel { Parent = this };
		contextMenuHost.AddClass( "explorer-context-menu-host" );

		RefreshListing();
	}

	private void CreateToolbarButton( Panel parent, string label, Action onClick, string extraClass = "" )
	{
		var button = new Button( label ) { Parent = parent };
		button.AddClass( "explorer-button" );
		if ( !string.IsNullOrWhiteSpace( extraClass ) )
			button.AddClass( extraClass );
		button.AddEventListener( "onclick", onClick );
	}

	private void PromptForCreate()
	{
		contextMenuHost.DeleteChildren( true );
		context.ShowMessageBox(
			new ComputerMessageBoxOptions
			{
				Title = "Create Item",
				Message = "Enter a folder name or a file name with extension, for example Notes.txt",
				Icon = "+",
				HasTextInput = true,
				TextInputPlaceholder = "FolderName or FileName.ext",
				Buttons = new[] { "Create", "Cancel" }
			},
			result =>
			{
				if ( !result.ButtonPressed.Equals( "Create", StringComparison.OrdinalIgnoreCase ) )
					return;

				var input = result.TextValue?.Trim() ?? "";
				if ( string.IsNullOrWhiteSpace( input ) )
					return;

				var createdName = input.Trim();
				var extension = Path.GetExtension( input );
				if ( string.IsNullOrWhiteSpace( extension ) )
				{
					createdName = PaneArchiveFileSystem.CreateFolder( archivePath, currentPath, createdName );
				}
				else
				{
					var fileName = Path.GetFileNameWithoutExtension( createdName );
					createdName = PaneArchiveFileSystem.CreateFile( archivePath, currentPath, fileName, extension.TrimStart( '.' ) );
				}

				selectedPath = BuildChildVirtualPath( currentPath, createdName );
				context.Runtime.PushNotification( "Created", $"{createdName} was created.", "+" );
				context.Runtime.RefreshTransientUi();
				RefreshListing();
			} );
	}

	private void PromptRenameSelected()
	{
		var targetPath = selectedPath ?? contextMenuPath;
		if ( string.IsNullOrWhiteSpace( targetPath ) )
			return;

		contextMenuHost.DeleteChildren( true );
		var currentName = ParsePath( targetPath ).LastOrDefault() ?? "";
		context.ShowMessageBox(
			new ComputerMessageBoxOptions
			{
				Title = "Rename Item",
				Message = "Enter a new file or folder name.",
				Icon = "R",
				HasTextInput = true,
				TextInputValue = currentName,
				TextInputPlaceholder = currentName,
				Buttons = new[] { "Rename", "Cancel" }
			},
			result =>
			{
				if ( !result.ButtonPressed.Equals( "Rename", StringComparison.OrdinalIgnoreCase ) )
					return;

				var newName = result.TextValue?.Trim() ?? "";
				if ( string.IsNullOrWhiteSpace( newName ) )
					return;

				var resolvedName = PaneArchiveFileSystem.Rename( archivePath, ParsePath( targetPath ), newName );
				selectedPath = BuildChildVirtualPath( currentPath, resolvedName );
				context.Runtime.PushNotification( "Renamed", $"{currentName} is now {resolvedName}.", "R" );
				context.Runtime.RefreshTransientUi();
				RefreshListing();
			} );
	}

	private void DeleteSelected()
	{
		var target = selectedPath ?? contextMenuPath;
		if ( string.IsNullOrWhiteSpace( target ) )
			return;

		HideContextMenu();
		var targetPath = ParsePath( target );
		if ( targetPath.Count == 0 )
			return;

		context.Runtime.DeleteVirtualPath( target );
		selectedPath = null;
		RefreshListing();
	}

	private void RestoreSelected()
	{
		var target = selectedPath ?? contextMenuPath;
		if ( string.IsNullOrWhiteSpace( target ) )
			return;

		HideContextMenu();
		if ( !IsRecycleBinPath( target ) )
			return;

		context.Runtime.RestoreVirtualPath( target );
		selectedPath = null;
		RefreshListing();
	}

	private void RefreshListing()
	{
		HideContextMenu();
		pathLabel.Text = currentPath.Count == 0 ? "My PC" : string.Join( " / ", currentPath );
		listHost.DeleteChildren( true );
		rowByPath.Clear();

		if ( currentPath.Count > 0 )
			AddParentDirectoryRow();

		IReadOnlyList<PaneArchiveItem> items;
		try
		{
			items = PaneArchiveFileSystem.GetItems( archivePath, currentPath );
		}
		catch ( Exception )
		{
			currentPath = documentsPath.ToArray();
			context.SaveValue( "path", "/" + string.Join( "/", currentPath ) );
			items = PaneArchiveFileSystem.GetItems( archivePath, currentPath );
		}

		foreach ( var item in items )
		{
			var row = new ExplorerItemRow(
				item.VirtualPath,
				item.IsDirectory,
				() => SelectPath( item.VirtualPath ),
				() => ActivateItem( item ),
				() => ShowContextMenu( item.VirtualPath ) )
			{
				Parent = listHost
			};
			row.AddClass( "explorer-row" );
			rowByPath[item.VirtualPath] = row;
			row.SetClass( "selected", string.Equals( selectedPath, item.VirtualPath, StringComparison.OrdinalIgnoreCase ) );

			var nameCell = new Panel { Parent = row };
			nameCell.AddClass( "explorer-cell explorer-name-cell" );
			CreateItemIcon( nameCell, item );
			new Label( item.Name ) { Parent = nameCell }.AddClass( "explorer-item-name" );
			AddCell( row, item.IsDirectory ? "Folder" : item.Extension.TrimStart( '.' ).ToUpperInvariant() + " File", "explorer-cell explorer-type-column" );
			AddCell( row, item.IsDirectory ? "-" : FormatSize( item.SizeBytes ), "explorer-cell explorer-size-column" );
		}

		SyncSelectionStyles();
	}

	private void AddParentDirectoryRow()
	{
		var parentPath = currentPath.Take( currentPath.Count - 1 ).ToArray();
		var row = new ExplorerItemRow(
			"/" + string.Join( "/", parentPath ),
			true,
			() => selectedPath = null,
			() => NavigateTo( parentPath ),
			() => { } )
		{
			Parent = listHost
		};
		row.AddClass( "explorer-row explorer-parent-row" );

		var nameCell = new Panel { Parent = row };
		nameCell.AddClass( "explorer-cell explorer-name-cell" );
		new Panel { Parent = nameCell }.AddClass( "explorer-parent-spacer" );
		new Label( ".." ) { Parent = nameCell }.AddClass( "explorer-item-name" );
		AddCell( row, "Parent", "explorer-cell explorer-type-column" );
		AddCell( row, "-", "explorer-cell explorer-size-column" );
	}

	private void ActivateItem( PaneArchiveItem item )
	{
		if ( item.IsDirectory )
		{
			NavigateTo( ParsePath( item.VirtualPath ) );
			return;
		}

		context.OpenVirtualPath( item.VirtualPath );
	}

	private void NavigateTo( IReadOnlyList<string> path, bool pushHistory = true )
	{
		var nextPath = path.ToArray();
		if ( currentPath.SequenceEqual( nextPath ) )
			return;

		if ( pushHistory )
		{
			backHistory.Push( currentPath.ToArray() );
			forwardHistory.Clear();
		}

		HideContextMenu();
		currentPath = nextPath;
		context.SaveValue( "path", "/" + string.Join( "/", currentPath ) );
		selectedPath = null;
		RefreshListing();
	}

	private void NavigateBack()
	{
		if ( backHistory.Count == 0 )
			return;

		forwardHistory.Push( currentPath.ToArray() );
		NavigateTo( backHistory.Pop(), false );
	}

	private void NavigateUp()
	{
		if ( currentPath.Count == 0 )
			return;

		NavigateTo( currentPath.Take( currentPath.Count - 1 ).ToArray() );
	}

	private void ShowContextMenu( string targetPath )
	{
		SelectPath( targetPath );
		contextMenuPath = targetPath;
		contextMenuHost.DeleteChildren( true );
		PositionContextMenuNearCursor();

		var menu = new Panel { Parent = contextMenuHost };
		menu.AddClass( "explorer-context-menu" );

		CreateContextMenuButton( menu, "Open", () =>
		{
			HideContextMenu();
			var item = PaneArchiveFileSystem.GetItems( archivePath, currentPath )
				.FirstOrDefault( x => x.VirtualPath.Equals( targetPath, StringComparison.OrdinalIgnoreCase ) );
			if ( item is not null )
				ActivateItem( item );
		} );
		CreateContextMenuButton( menu, "Rename", PromptRenameSelected );
		CreateContextMenuButton( menu, "New File/Folder", PromptForCreate );
		if ( IsRecycleBinPath( targetPath ) )
			CreateContextMenuButton( menu, "Restore", RestoreSelected );
		CreateContextMenuButton( menu, "Delete", DeleteSelected );
	}

	private void ShowFolderContextMenu()
	{
		selectedPath = null;
		contextMenuPath = null;
		contextMenuHost.DeleteChildren( true );
		SyncSelectionStyles();
		PositionContextMenuNearCursor();

		var menu = new Panel { Parent = contextMenuHost };
		menu.AddClass( "explorer-context-menu" );
		CreateContextMenuButton( menu, "New File/Folder", PromptForCreate );
	}

	private void HideContextMenu()
	{
		contextMenuPath = null;
		contextMenuHost.DeleteChildren( true );
	}

	private void CreateContextMenuButton( Panel parent, string label, Action onClick )
	{
		var button = new Button( label ) { Parent = parent };
		button.AddClass( "explorer-context-button" );
		button.AddEventListener( "onclick", onClick );
	}

	private static string BuildChildVirtualPath( IReadOnlyList<string> parentPath, string childName )
	{
		return "/" + string.Join( "/", parentPath.Append( childName ) );
	}

	private void NavigateForward()
	{
		if ( forwardHistory.Count == 0 )
			return;

		backHistory.Push( currentPath.ToArray() );
		NavigateTo( forwardHistory.Pop(), false );
	}

	private static IReadOnlyList<string> ParsePath( string value )
	{
		return value
			.Trim()
			.TrimStart( '/' )
			.Split( '/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
	}

	private static bool IsRecycleBinPath( string virtualPath )
	{
		return IsRecycleBinPath( ParsePath( virtualPath ) );
	}

	private static bool IsRecycleBinPath( IReadOnlyList<string> path )
	{
		return path.Count >= RecycleBinPath.Count && path.Take( RecycleBinPath.Count )
			.SequenceEqual( RecycleBinPath, StringComparer.OrdinalIgnoreCase );
	}

	private void CreateItemIcon( Panel parent, PaneArchiveItem item )
	{
		var icon = new Label( ResolveFallbackIconText( item ) ) { Parent = parent };
		icon.AddClass( "explorer-item-icon" );

		var texturePath = ResolveTexturePath( item );
		if ( string.IsNullOrWhiteSpace( texturePath ) )
			return;

		icon.Text = "";
		icon.AddClass( "has-texture" );
		icon.Style.SetBackgroundImage( texturePath );
	}

	private string ResolveTexturePath( PaneArchiveItem item )
	{
		if ( item.IsDirectory )
		{
			if ( IsRecycleBinPath( item.VirtualPath ) && ParsePath( item.VirtualPath ).Count == RecycleBinPath.Count )
			{
				var recycleTexture = TryResolveTexturePath( "App_recycleBin" );
				if ( !string.IsNullOrWhiteSpace( recycleTexture ) )
					return recycleTexture;
			}

			return TryResolveTexturePath( "folder" );
		}

		if ( item.Extension.Equals( ".exe", StringComparison.OrdinalIgnoreCase ) )
		{
			var app = context.Runtime.Apps.FirstOrDefault( x => x.ResolvedExecutableName.Equals( item.Name, StringComparison.OrdinalIgnoreCase ) );
			if ( app is not null )
			{
				var appTexture = ResolveAppTexturePath( app );
				if ( !string.IsNullOrWhiteSpace( appTexture ) )
					return appTexture;
			}
		}

		var extensionTextureName = $"Ext_{item.Extension.TrimStart( '.' ).ToLowerInvariant()}";
		return TryResolveTexturePath( extensionTextureName );
	}

	private string TryResolveTexturePath( string textureName )
	{
		if ( string.IsNullOrWhiteSpace( textureName ) )
			return "";

		var themeName = string.IsNullOrWhiteSpace( context.Computer.ThemeName )
			? "default"
			: context.Computer.ThemeName.Trim();
		var path = $"textures/themes/{themeName}/{textureName}.png";
		try
		{
			return FileSystem.Mounted.FileExists( path ) ? path : "";
		}
		catch ( Exception )
		{
			return "";
		}
	}

	private static string ResolveAppTextureKey( ComputerAppDescriptor app )
	{
		return app.Id switch
		{
			"system.about" => "about",
			"system.calculator" => "calculator",
			"system.settings" => "controlPanel",
			"system.notepad" => "notepad",
			"system.paint" => "paint",
			"system.paneexplorer" => "paneExplorer",
			"system.ridge" => "ridge",
			"system.taskmanager" => "taskManager",
			_ => Path.GetFileNameWithoutExtension( app.ResolvedExecutableName )
		};
	}

	private string ResolveAppTexturePath( ComputerAppDescriptor app )
	{
		if ( app.Id.Equals( "system.mediaplayer", StringComparison.OrdinalIgnoreCase ) )
			return TryResolveTexturePath( "Ext_mp4" );

		return TryResolveTexturePath( $"App_{ResolveAppTextureKey( app )}" );
	}

	private static string ResolveFallbackIconText( PaneArchiveItem item )
	{
		if ( item.IsDirectory )
			return "FD";

		var extension = item.Extension.TrimStart( '.' ).ToUpperInvariant();
		if ( !string.IsNullOrWhiteSpace( extension ) )
			return extension.Length <= 3 ? extension : extension[..3];

		return "FI";
	}

	private bool IsAtRootPath()
	{
		return currentPath.Count == 0;
	}

	private bool IsAtDocumentsPath()
	{
		return PathEquals( currentPath, documentsPath );
	}

	private bool IsAtRecycleBinPath()
	{
		return IsRecycleBinPath( currentPath );
	}

	private static bool PathEquals( IReadOnlyList<string> left, IReadOnlyList<string> right )
	{
		return left.Count == right.Count && left.SequenceEqual( right, StringComparer.OrdinalIgnoreCase );
	}

	private static void AddCell( Panel row, string text, string className, bool header = false )
	{
		var cell = new Label( text ) { Parent = row };
		cell.AddClass( className );
		if ( header )
			cell.AddClass( "explorer-header-cell" );
	}

	private static void AddCell( Panel row, string text, bool header = false )
	{
		AddCell( row, text, "explorer-cell", header );
	}

	private static string FormatSize( long bytes )
	{
		if ( bytes <= 0 )
			return "0 B";

		if ( bytes < 1024 )
			return $"{bytes} B";

		if ( bytes < 1024 * 1024 )
			return $"{bytes / 1024f:0.0} KB";

		return $"{bytes / 1024f / 1024f:0.0} MB";
	}

	private void SelectPath( string virtualPath )
	{
		selectedPath = virtualPath;
		SyncSelectionStyles();
	}

	private void ClearSelectionAndHideContextMenu()
	{
		selectedPath = null;
		SyncSelectionStyles();
		HideContextMenu();
	}

	private void SyncSelectionStyles()
	{
		foreach ( var row in rowByPath )
			row.Value.SetClass( "selected", string.Equals( selectedPath, row.Key, StringComparison.OrdinalIgnoreCase ) );
	}

	private void PositionContextMenuNearCursor()
	{
		var position = MousePosition;
		contextMenuHost.Style.Left = Length.Pixels( MathF.Max( 4f, position.x + 12f ) );
		contextMenuHost.Style.Top = Length.Pixels( MathF.Max( 4f, position.y + 4f ) );
	}
}

public sealed class ExplorerItemRow : Panel
{
	private readonly string virtualPath;
	private readonly bool isDirectory;
	private readonly Action select;
	private readonly Action activate;
	private readonly Action openContextMenu;

	public ExplorerItemRow( string virtualPath, bool isDirectory, Action select, Action activate, Action openContextMenu )
	{
		this.virtualPath = virtualPath;
		this.isDirectory = isDirectory;
		this.select = select;
		this.activate = activate;
		this.openContextMenu = openContextMenu;
	}

	public override bool WantsDrag => !isDirectory;

	protected override void OnMouseDown( MousePanelEvent e )
	{
		base.OnMouseDown( e );

		if ( e.Button == "mouseright" )
			openContextMenu();
		else
			select();

		e.StopPropagation();
	}

	protected override void OnDoubleClick( MousePanelEvent e )
	{
		base.OnDoubleClick( e );

		if ( e.Button == "mouseleft" )
			activate();

		e.StopPropagation();
	}

	protected override void OnDragStart( DragEvent e )
	{
		base.OnDragStart( e );
		ComputerUiDragState.BeginDrag( virtualPath );
	}
}

public sealed class ExplorerListPanel : Panel
{
	private readonly Action openBackgroundContextMenu;
	private readonly Action hideContextMenu;

	public ExplorerListPanel( Action openBackgroundContextMenu, Action hideContextMenu )
	{
		this.openBackgroundContextMenu = openBackgroundContextMenu;
		this.hideContextMenu = hideContextMenu;
	}

	protected override void OnMouseDown( MousePanelEvent e )
	{
		base.OnMouseDown( e );

		if ( e.Button == "mouseright" )
			openBackgroundContextMenu();
		else if ( e.Button == "mouseleft" )
			hideContextMenu();

		e.StopPropagation();
	}
}