Code/InteractiveComputer/Core/PaneArchiveFileSystem.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;

namespace PaneOS.InteractiveComputer.Core;

public sealed class PaneArchiveItem
{
	public string Name { get; set; } = "";
	public bool IsDirectory { get; set; }
	public string Extension { get; set; } = "";
	public string VirtualPath { get; set; } = "";
	public long SizeBytes { get; set; }
}

public static class PaneArchiveFileSystem
{
	private sealed class ArchiveEntryModel
	{
		public List<string> Segments { get; set; } = new();
		public bool IsDirectory { get; set; }
		public byte[] Content { get; set; } = Array.Empty<byte>();
	}

	public static void EnsureArchive( string archivePath, string userName, IEnumerable<ComputerAppDescriptor> apps )
	{
		var entries = ReadEntries( archivePath );
		var normalizedUserName = NormalizeDisplayName( userName );
		MigrateLegacyPlayerFolder( entries, normalizedUserName );

		EnsureDirectory( entries, "C:" );
		EnsureDirectory( entries, "C:", "Users" );
		EnsureDirectory( entries, "C:", "Users", normalizedUserName );
		EnsureDirectory( entries, "C:", "Users", normalizedUserName, "My Documents" );
		EnsureDirectory( entries, "C:", "Recycle Bin" );
		EnsureDirectory( entries, "C:", "Apps" );

		foreach ( var app in apps )
		{
			EnsureDirectory( entries, "C:", "Apps", app.Title );
			EnsureFile(
				entries,
				Encoding.UTF8.GetBytes( $"app_id={app.Id}\nexecutable={app.ResolvedExecutableName}\n" ),
				"C:",
				"Apps",
				app.Title,
				app.ResolvedExecutableName );
		}

		WriteEntries( archivePath, entries );
	}

	public static List<PaneArchiveItem> GetItems( string archivePath, IReadOnlyList<string> displayPath )
	{
		var prefix = displayPath.ToArray();
		var entries = ReadEntries( archivePath );
		var children = new Dictionary<string, PaneArchiveItem>( StringComparer.OrdinalIgnoreCase );

		foreach ( var entry in entries )
		{
			if ( entry.Segments.Count <= prefix.Length || !IsUnderPath( entry.Segments, prefix ) )
				continue;

			var childName = entry.Segments[prefix.Length];
			var childSegments = entry.Segments.Take( prefix.Length + 1 ).ToArray();
			var childKey = string.Join( "/", childSegments );

			if ( !children.TryGetValue( childKey, out var child ) )
			{
				var extension = Path.GetExtension( childName );
				child = new PaneArchiveItem
				{
					Name = childName,
					IsDirectory = entry.IsDirectory || entry.Segments.Count > prefix.Length + 1,
					Extension = extension,
					VirtualPath = "/" + string.Join( "/", childSegments )
				};
				children[childKey] = child;
			}

			if ( !child.IsDirectory && PathEquals( entry.Segments, childSegments ) )
				child.SizeBytes = entry.Content.LongLength;
		}

		return children.Values
			.Where( x => !x.Name.StartsWith( "$paneos-", StringComparison.OrdinalIgnoreCase ) )
			.OrderByDescending( x => x.IsDirectory )
			.ThenBy( x => x.Name, StringComparer.OrdinalIgnoreCase )
			.ToList();
	}

	public static string CreateFolder( string archivePath, IReadOnlyList<string> parentPath, string folderName )
	{
		if ( string.IsNullOrWhiteSpace( folderName ) )
			return "";

		var entries = ReadEntries( archivePath );
		var resolvedName = ResolveUniqueChildName( entries, parentPath, folderName.Trim() );
		EnsureDirectory( entries, parentPath.Concat( new[] { resolvedName } ).ToArray() );
		WriteEntries( archivePath, entries );
		return resolvedName;
	}

	public static string CreateFile( string archivePath, IReadOnlyList<string> parentPath, string fileName, string extension, string? content = null )
	{
		if ( string.IsNullOrWhiteSpace( fileName ) )
			return "";

		var safeExtension = extension?.Trim().TrimStart( '.' ) ?? "";
		var requestedName = string.IsNullOrWhiteSpace( safeExtension )
			? fileName.Trim()
			: $"{fileName.Trim()}.{safeExtension}";

		var entries = ReadEntries( archivePath );
		var fullName = ResolveUniqueChildName( entries, parentPath, requestedName );
		EnsureFile( entries, Encoding.UTF8.GetBytes( content ?? "" ), parentPath.Concat( new[] { fullName } ).ToArray() );
		WriteEntries( archivePath, entries );
		return fullName;
	}

	public static string ReadTextFile( string archivePath, IReadOnlyList<string> filePath )
	{
		var entries = ReadEntries( archivePath );
		var file = entries.FirstOrDefault( x => !x.IsDirectory && PathEquals( x.Segments, filePath ) );
		return file is null ? "" : Encoding.UTF8.GetString( file.Content );
	}

	public static void WriteTextFile( string archivePath, IReadOnlyList<string> filePath, string content )
	{
		if ( filePath.Count == 0 )
			return;

		var entries = ReadEntries( archivePath );
		EnsureFile( entries, Encoding.UTF8.GetBytes( content ), filePath.ToArray() );
		WriteEntries( archivePath, entries );
	}

	public static string Rename( string archivePath, IReadOnlyList<string> targetPath, string newName )
	{
		if ( targetPath.Count == 0 || string.IsNullOrWhiteSpace( newName ) )
			return targetPath.LastOrDefault() ?? "";

		var entries = ReadEntries( archivePath );
		var sourcePrefix = targetPath.ToArray();
		var requestedName = NormalizeRenamedItemName( targetPath.Last(), newName.Trim() );
		var parentPath = targetPath.Take( targetPath.Count - 1 ).ToArray();
		var resolvedName = ResolveUniqueChildName( entries, parentPath, requestedName, sourcePrefix );
		var destinationPrefix = parentPath.Append( resolvedName ).ToArray();

		foreach ( var entry in entries.Where( x => IsUnderPath( x.Segments, sourcePrefix ) ) )
		{
			entry.Segments = destinationPrefix.Concat( entry.Segments.Skip( sourcePrefix.Length ) ).ToList();
		}

		WriteEntries( archivePath, entries );
		return resolvedName;
	}

	public static void Move( string archivePath, IReadOnlyList<string> sourcePath, IReadOnlyList<string> destinationPath )
	{
		if ( sourcePath.Count == 0 || destinationPath.Count == 0 )
			return;

		var entries = ReadEntries( archivePath );
		MoveEntries( entries, sourcePath.ToArray(), destinationPath.ToArray() );
		WriteEntries( archivePath, entries );
	}

	public static bool Exists( string archivePath, IReadOnlyList<string> targetPath )
	{
		var entries = ReadEntries( archivePath );
		return entries.Any( x => PathEquals( x.Segments, targetPath ) );
	}

	public static void Delete( string archivePath, IReadOnlyList<string> targetPath )
	{
		if ( targetPath.Count == 0 )
			return;

		var entries = ReadEntries( archivePath );
		entries.RemoveAll( x => IsUnderPath( x.Segments, targetPath ) );
		DeleteRecycleBinMetadata( entries, targetPath );
		WriteEntries( archivePath, entries );
	}

	public static string MoveToRecycleBin( string archivePath, IReadOnlyList<string> targetPath )
	{
		if ( targetPath.Count == 0 )
			return "";

		var entries = ReadEntries( archivePath );
		var recycleRoot = new[] { "C:", "Recycle Bin" };
		EnsureDirectory( entries, recycleRoot );
		EnsureDirectory( entries, recycleRoot.Concat( new[] { "$paneos-meta" } ).ToArray() );
		var sourceName = targetPath.Last();
		var recycleName = ResolveUniqueChildName( entries, recycleRoot, sourceName );
		var destinationPath = recycleRoot.Concat( new[] { recycleName } ).ToArray();
		var originalParentPath = "/" + string.Join( "/", targetPath.Take( targetPath.Count - 1 ) );
		MoveEntries( entries, targetPath.ToArray(), destinationPath );
		EnsureFile(
			entries,
			Encoding.UTF8.GetBytes( originalParentPath ),
			"C:",
			"Recycle Bin",
			"$paneos-meta",
			$"{recycleName}.restore.txt" );
		WriteEntries( archivePath, entries );
		return "/" + string.Join( "/", destinationPath );
	}

	public static string RestoreFromRecycleBin( string archivePath, IReadOnlyList<string> recyclePath )
	{
		if ( recyclePath.Count < 3 || !IsUnderPath( recyclePath, new[] { "C:", "Recycle Bin" } ) )
			return "";

		var entries = ReadEntries( archivePath );
		var recycleName = recyclePath.Last();
		var metadataPath = new[] { "C:", "Recycle Bin", "$paneos-meta", $"{recycleName}.restore.txt" };
		var originalParentPath = ReadTextFileFromEntries( entries, metadataPath );
		if ( string.IsNullOrWhiteSpace( originalParentPath ) )
			originalParentPath = "/C:/Users";

		var parentPath = ParseVirtualPath( originalParentPath );
		EnsureDirectory( entries, parentPath.ToArray() );
		var desiredDestination = parentPath.Concat( new[] { recycleName } ).ToArray();
		if ( IsEmptyDirectory( entries, desiredDestination ) )
			entries.RemoveAll( x => x.IsDirectory && PathEquals( x.Segments, desiredDestination ) );

		var restoredName = ResolveUniqueChildName( entries, parentPath, recycleName );
		var destinationPath = parentPath.Concat( new[] { restoredName } ).ToArray();
		MoveEntries( entries, recyclePath.ToArray(), destinationPath );
		entries.RemoveAll( x => !x.IsDirectory && PathEquals( x.Segments, metadataPath ) );
		WriteEntries( archivePath, entries );
		return "/" + string.Join( "/", destinationPath );
	}

	public static List<ComputerStorageBreakdownItem> BuildStorageBreakdown( string archivePath, IEnumerable<ComputerAppDescriptor> apps )
	{
		var entries = ReadEntries( archivePath );
		var breakdown = new List<ComputerStorageBreakdownItem>();

		foreach ( var app in apps.OrderBy( x => x.Title ) )
		{
			breakdown.Add( new ComputerStorageBreakdownItem
			{
				Name = app.Title,
				SizeGb = app.StorageSpaceUsedGb
			} );
		}

		var fileTypes = entries
			.Where( x => !x.IsDirectory )
			.Where( x => x.Segments.Count >= 2 && !(x.Segments[0] == "C:" && x.Segments[1] == "Apps") )
			.GroupBy( x =>
			{
				var ext = Path.GetExtension( x.Segments.Last() );
				return string.IsNullOrWhiteSpace( ext ) ? "[no extension]" : ext.ToLowerInvariant();
			} )
			.OrderBy( x => x.Key, StringComparer.OrdinalIgnoreCase );

		foreach ( var fileType in fileTypes )
		{
			breakdown.Add( new ComputerStorageBreakdownItem
			{
				Name = fileType.Key,
				SizeGb = BytesToGb( fileType.Sum( x => (double)x.Content.LongLength ) )
			} );
		}

		return breakdown;
	}

	public static bool IsDirectory( string archivePath, IReadOnlyList<string> targetPath )
	{
		var entries = ReadEntries( archivePath );
		return entries.Any( x => x.IsDirectory && PathEquals( x.Segments, targetPath ) );
	}

	public static string NormalizeDisplayName( string? value )
	{
		if ( string.IsNullOrWhiteSpace( value ) )
			return "Player";

		var cleaned = new string( value.Trim().Select( x => Path.GetInvalidFileNameChars().Contains( x ) ? '_' : x ).ToArray() );
		return string.IsNullOrWhiteSpace( cleaned ) ? "Player" : cleaned;
	}

	private static List<ArchiveEntryModel> ReadEntries( string archivePath )
	{
		if ( !ComputerSandboxStorage.FileExists( archivePath ) )
			return new List<ArchiveEntryModel>();

		var entries = new List<ArchiveEntryModel>();
		using var stream = new MemoryStream( ComputerSandboxStorage.ReadAllBytes( archivePath ), writable: false );
		using var archive = new ZipArchive( stream, ZipArchiveMode.Read );

		foreach ( var entry in archive.Entries )
		{
			var normalizedName = entry.FullName.Replace( '\\', '/' );
			var isDirectory = normalizedName.EndsWith( "/" );
			var trimmedName = normalizedName.TrimEnd( '/' );
			var segments = trimmedName.Split( '/', StringSplitOptions.RemoveEmptyEntries )
				.Select( DecodeName )
				.ToList();

			using var entryStream = entry.Open();
			using var memoryStream = new MemoryStream();
			entryStream.CopyTo( memoryStream );

			entries.Add( new ArchiveEntryModel
			{
				Segments = segments,
				IsDirectory = isDirectory,
				Content = memoryStream.ToArray()
			} );
		}

		return entries;
	}

	private static void WriteEntries( string archivePath, List<ArchiveEntryModel> entries )
	{
		using var stream = new MemoryStream();
		{
			using var archive = new ZipArchive( stream, ZipArchiveMode.Create, leaveOpen: true );

			foreach ( var entry in entries.OrderBy( x => EncodePath( x.Segments, x.IsDirectory ), StringComparer.OrdinalIgnoreCase ) )
			{
				var zipEntry = archive.CreateEntry( EncodePath( entry.Segments, entry.IsDirectory ), CompressionLevel.Fastest );
				if ( entry.IsDirectory )
					continue;

				using var entryStream = zipEntry.Open();
				entryStream.Write( entry.Content, 0, entry.Content.Length );
			}
		}

		ComputerSandboxStorage.WriteAllBytes( archivePath, stream.ToArray() );
	}

	private static void EnsureDirectory( List<ArchiveEntryModel> entries, params string[] segments )
	{
		if ( entries.Any( x => x.IsDirectory && PathEquals( x.Segments, segments ) ) )
			return;

		entries.Add( new ArchiveEntryModel
		{
			Segments = segments.ToList(),
			IsDirectory = true
		} );
	}

	private static void MigrateLegacyPlayerFolder( List<ArchiveEntryModel> entries, string normalizedUserName )
	{
		if ( normalizedUserName.Equals( "Player", StringComparison.OrdinalIgnoreCase ) )
			return;

		var sourcePrefix = new[] { "C:", "Users", "Player" };
		var destinationPrefix = new[] { "C:", "Users", normalizedUserName };
		var hasSource = entries.Any( x => IsUnderPath( x.Segments, sourcePrefix ) );
		var hasDestination = entries.Any( x => IsUnderPath( x.Segments, destinationPrefix ) );
		if ( !hasSource || hasDestination )
			return;

		foreach ( var entry in entries.Where( x => IsUnderPath( x.Segments, sourcePrefix ) ) )
		{
			entry.Segments = destinationPrefix
				.Concat( entry.Segments.Skip( sourcePrefix.Length ) )
				.ToList();
		}
	}

	private static void EnsureFile( List<ArchiveEntryModel> entries, byte[] content, params string[] segments )
	{
		var existing = entries.FirstOrDefault( x => !x.IsDirectory && PathEquals( x.Segments, segments ) );
		if ( existing is not null )
		{
			existing.Content = content;
			return;
		}

		entries.Add( new ArchiveEntryModel
		{
			Segments = segments.ToList(),
			Content = content
		} );
	}

	private static void MoveEntries( List<ArchiveEntryModel> entries, string[] sourcePrefix, string[] destinationPrefix )
	{
		foreach ( var entry in entries.Where( x => IsUnderPath( x.Segments, sourcePrefix ) ) )
		{
			entry.Segments = destinationPrefix.Concat( entry.Segments.Skip( sourcePrefix.Length ) ).ToList();
		}
	}

	private static string ResolveUniqueChildName( List<ArchiveEntryModel> entries, IReadOnlyList<string> parentPath, string desiredName, IReadOnlyList<string>? sourcePath = null )
	{
		var name = desiredName;
		var stem = Path.GetFileNameWithoutExtension( desiredName );
		var extension = Path.GetExtension( desiredName );
		var suffix = 2;
		while ( entries.Any( x => x.Segments.Count >= parentPath.Count + 1 &&
			IsUnderPath( x.Segments, parentPath ) &&
			(sourcePath is null || !IsUnderPath( x.Segments, sourcePath )) &&
			x.Segments[parentPath.Count].Equals( name, StringComparison.OrdinalIgnoreCase ) ) )
		{
			name = string.IsNullOrWhiteSpace( extension )
				? $"{stem} ({suffix++})"
				: $"{stem} ({suffix++}){extension}";
		}

		return name;
	}

	private static bool IsEmptyDirectory( List<ArchiveEntryModel> entries, IReadOnlyList<string> path )
	{
		return entries.Any( x => x.IsDirectory && PathEquals( x.Segments, path ) ) &&
			!entries.Any( x => x.Segments.Count > path.Count && IsUnderPath( x.Segments, path ) );
	}

	private static string NormalizeRenamedItemName( string existingName, string requestedName )
	{
		if ( string.IsNullOrWhiteSpace( requestedName ) )
			return existingName;

		if ( string.IsNullOrWhiteSpace( Path.GetExtension( existingName ) ) || !string.IsNullOrWhiteSpace( Path.GetExtension( requestedName ) ) )
			return requestedName;

		return $"{requestedName}{Path.GetExtension( existingName )}";
	}

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

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

	private static string ReadTextFileFromEntries( List<ArchiveEntryModel> entries, IReadOnlyList<string> filePath )
	{
		var file = entries.FirstOrDefault( x => !x.IsDirectory && PathEquals( x.Segments, filePath ) );
		return file is null ? "" : Encoding.UTF8.GetString( file.Content );
	}

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

	private static void DeleteRecycleBinMetadata( List<ArchiveEntryModel> entries, IReadOnlyList<string> targetPath )
	{
		if ( targetPath.Count < 3 || !IsUnderPath( targetPath, new[] { "C:", "Recycle Bin" } ) )
			return;

		var recycleName = targetPath[2];
		entries.RemoveAll( x => !x.IsDirectory && PathEquals( x.Segments, new[] { "C:", "Recycle Bin", "$paneos-meta", $"{recycleName}.restore.txt" } ) );
	}

	private static string EncodePath( IReadOnlyList<string> segments, bool isDirectory )
	{
		var encoded = string.Join( "/", segments.Select( EncodeName ) );
		return isDirectory ? $"{encoded}/" : encoded;
	}

	private static string EncodeName( string name )
	{
		return Convert.ToBase64String( Encoding.UTF8.GetBytes( name ) );
	}

	private static string DecodeName( string encoded )
	{
		return Encoding.UTF8.GetString( Convert.FromBase64String( encoded ) );
	}

	private static float BytesToGb( double bytes )
	{
		return (float)(bytes / 1024d / 1024d / 1024d);
	}
}