Editor/Launcher/HotCodeInstaller.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;

/// <summary>
/// Adds HotCode to other projects by copying it into their Libraries folder, which is where
/// s&amp;box's Library Manager puts libraries too. Next time that project is opened in s&amp;box,
/// HotCode loads with it.
///
/// Until HotCode is published, this is the way to get it into another project; afterwards the
/// Library Manager can install (and update) it as well.
/// </summary>
public static class HotCodeInstaller
{
	public const string Ident = "hotcode";

	/// <summary>
	/// A file only HotCode has, so it can be recognised whatever org it's published under.
	/// </summary>
	static readonly string MarkerFile = Path.Combine( "Editor", "Launcher", "HotCodeInstaller.cs" );

	/// <summary>
	/// "org.hotcode", using the org from HotCode's own .sbproj (the same naming the Library Manager uses).
	/// </summary>
	public static string FolderName( string sourceRoot )
	{
		try
		{
			using var doc = JsonDocument.Parse( File.ReadAllText( Path.Combine( sourceRoot, Ident + ".sbproj" ) ) );
			if ( doc.RootElement.TryGetProperty( "Org", out var org ) && org.GetString() is { Length: > 0 } name )
				return $"{name.ToLowerInvariant()}.{Ident}";
		}
		catch ( Exception e ) when ( e is IOException or JsonException or UnauthorizedAccessException )
		{
		}

		return Ident;
	}

	/// <summary>
	/// Folders never copied: build output, editor state, and the logo source script.
	/// </summary>
	static readonly HashSet<string> SkipFolders = new( StringComparer.OrdinalIgnoreCase ) { "obj", "bin", ".vs", ".git", ".idea", "logo", "Properties" };

	/// <summary>
	/// Files never copied: s&amp;box generates these per project, with paths that only make sense where they were made.
	/// </summary>
	static readonly HashSet<string> SkipExtensions = new( StringComparer.OrdinalIgnoreCase ) { ".csproj", ".sln", ".slnx", ".user" };

	/// <summary>
	/// Where HotCode is installed in a project (any folder under Libraries whose .sbproj is HotCode's), or null.
	/// </summary>
	public static string FindInstalled( string projectRoot )
	{
		var libraries = Path.Combine( projectRoot, "Libraries" );
		if ( !Directory.Exists( libraries ) ) return null;

		foreach ( var folder in Directory.EnumerateDirectories( libraries ) )
		{
			foreach ( var sbproj in Directory.EnumerateFiles( folder, "*.sbproj" ) )
				if ( IsHotCode( sbproj ) ) return folder;
		}

		return null;
	}

	/// <summary>
	/// True when an .sbproj is HotCode's own: ident "hotcode" and HotCode's files next to it.
	/// Doesn't depend on the org, so renaming or changing the org doesn't break anything.
	/// </summary>
	public static bool IsHotCode( string sbproj )
	{
		try
		{
			if ( !File.Exists( Path.Combine( Path.GetDirectoryName( sbproj ), MarkerFile ) ) ) return false;

			using var doc = JsonDocument.Parse( File.ReadAllText( sbproj ) );
			return doc.RootElement.TryGetProperty( "Ident", out var ident ) && string.Equals( ident.GetString(), Ident, StringComparison.OrdinalIgnoreCase );
		}
		catch ( Exception e ) when ( e is IOException or JsonException or UnauthorizedAccessException )
		{
			return false;
		}
	}

	/// <summary>
	/// Copies HotCode from <paramref name="sourceRoot"/> into the project. An existing copy is moved
	/// to the Recycle Bin first (via <paramref name="recycle"/>), so updating never loses anything for good.
	/// Returns the folder HotCode now lives in. Throws <see cref="InvalidOperationException"/> with a readable message.
	/// </summary>
	public static string Install( string sourceRoot, string projectRoot, Action<string> recycle )
	{
		sourceRoot = Path.GetFullPath( sourceRoot ).TrimEnd( '\\', '/' );
		projectRoot = Path.GetFullPath( projectRoot ).TrimEnd( '\\', '/' );

		if ( !File.Exists( Path.Combine( sourceRoot, Ident + ".sbproj" ) ) )
			throw new InvalidOperationException( "Couldn't find HotCode's own files to copy." );

		if ( ProjectFiles.IsUnder( projectRoot, sourceRoot ) || ProjectFiles.IsUnder( sourceRoot, projectRoot ) )
			throw new InvalidOperationException( "That's the project HotCode is being developed in - it already has it." );

		if ( !Directory.Exists( projectRoot ) || !Directory.EnumerateFiles( projectRoot, "*.sbproj" ).Any() )
			throw new InvalidOperationException( "That folder isn't an s&box project (no .sbproj file)." );

		// Replace an existing copy wherever it is, otherwise use the standard folder name
		var target = FindInstalled( projectRoot ) ?? Path.Combine( projectRoot, "Libraries", FolderName( sourceRoot ) );
		if ( ProjectFiles.IsUnder( target, sourceRoot ) )
			throw new InvalidOperationException( "Refusing to overwrite HotCode's own source folder." );

		// Copy into a staging folder first, so a failed copy never leaves a half-installed library
		var staging = target + ".installing";
		if ( Directory.Exists( staging ) ) Directory.Delete( staging, recursive: true );
		CopyFolder( sourceRoot, staging );

		if ( Directory.Exists( target ) ) recycle( target );
		Directory.Move( staging, target );

		return target;
	}

	/// <summary>
	/// Removes HotCode from a project (to the Recycle Bin). Returns false if it wasn't installed.
	/// </summary>
	public static bool Uninstall( string sourceRoot, string projectRoot, Action<string> recycle )
	{
		var installed = FindInstalled( projectRoot );
		if ( installed is null ) return false;

		if ( ProjectFiles.IsUnder( installed, sourceRoot ) )
			throw new InvalidOperationException( "That's HotCode's own source folder - not removing it." );

		recycle( installed );
		return true;
	}

	private static void CopyFolder( string from, string to )
	{
		Directory.CreateDirectory( to );

		foreach ( var file in Directory.EnumerateFiles( from ) )
		{
			if ( SkipExtensions.Contains( Path.GetExtension( file ) ) ) continue;
			File.Copy( file, Path.Combine( to, Path.GetFileName( file ) ), overwrite: true );
		}

		foreach ( var folder in Directory.EnumerateDirectories( from ) )
		{
			if ( SkipFolders.Contains( Path.GetFileName( folder ) ) ) continue;
			CopyFolder( folder, Path.Combine( to, Path.GetFileName( folder ) ) );
		}
	}
}