Editor/ConnecterWorkspaceReader.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Editor;

public static class ConnecterWorkspaceReader
{
	private const string SavedWorkspaceFileName = "workspace.txt";

	private static readonly Regex WindowsPathRegex = new( @"[A-Za-z]:\\[^""\x00-\x1F<>|?*]+", RegexOptions.Compiled );
	private static readonly Regex SettingsRepositoryRegex = new(
		@"<setting\b[^>]*\bkey\s*=\s*[""']2007[""'][^>]*>(?<value>.*?)</setting>",
		RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline );

	public static ConnecterWorkspace Read( string workspacePath = null, bool allowAutoDiscover = true )
	{
		workspacePath = ResolveWorkspacePath( workspacePath, allowAutoDiscover );
		if ( string.IsNullOrWhiteSpace( workspacePath ) )
			return new ConnecterWorkspace( string.Empty, [] );

		var repositories = new List<ConnecterRepository>();
		repositories.AddRange( ReadRepositoriesFromDatabase( Path.Combine( workspacePath, "default.dcdb" ) ) );

		if ( repositories.Count == 0 )
		{
			repositories.AddRange( ReadRepositoriesFromSettingsXml( Path.Combine( workspacePath, "settings.xml" ) ) );
		}

		return new ConnecterWorkspace( workspacePath, DeduplicateRepositories( repositories ) );
	}

	public static string GetSavedWorkspacePath()
	{
		try
		{
			var settingsPath = GetSavedWorkspaceFilePath();
			if ( File.Exists( settingsPath ) )
				return File.ReadAllText( settingsPath ).Trim();
		}
		catch
		{
		}

		return string.Empty;
	}

	public static void SetSavedWorkspacePath( string workspacePath )
	{
		try
		{
			var settingsPath = GetSavedWorkspaceFilePath();
			var settingsFolder = Path.GetDirectoryName( settingsPath );
			if ( !string.IsNullOrWhiteSpace( settingsFolder ) )
				Directory.CreateDirectory( settingsFolder );

			File.WriteAllText( settingsPath, NormalizeFullPath( workspacePath ) );
		}
		catch
		{
		}
	}

	public static void ClearSavedWorkspacePath()
	{
		try
		{
			var settingsPath = GetSavedWorkspaceFilePath();
			var settingsFolder = Path.GetDirectoryName( settingsPath );
			if ( !string.IsNullOrWhiteSpace( settingsFolder ) )
				Directory.CreateDirectory( settingsFolder );

			File.WriteAllText( settingsPath, string.Empty );
		}
		catch
		{
		}
	}

	public static IReadOnlyList<string> DiscoverWorkspacePaths( IEnumerable<string> candidatePaths = null )
	{
		return (candidatePaths ?? GetDefaultWorkspaceCandidates())
			.Select( NormalizeFullPath )
			.Where( IsConnecterWorkspacePath )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToList();
	}

	public static bool IsConnecterWorkspacePath( string workspacePath )
	{
		if ( string.IsNullOrWhiteSpace( workspacePath ) )
			return false;

		var normalized = NormalizeFullPath( workspacePath );
		return Directory.Exists( normalized )
			&& (File.Exists( Path.Combine( normalized, "default.dcdb" ) ) || File.Exists( Path.Combine( normalized, "settings.xml" ) ));
	}

	public static IReadOnlyList<ConnecterRepository> ReadRepositoriesFromSettingsXml( string settingsPath )
	{
		if ( !File.Exists( settingsPath ) )
			return [];

		var text = File.ReadAllText( settingsPath );
		var match = SettingsRepositoryRegex.Match( text );

		if ( !match.Success )
			return [];

		var value = WebUtility.HtmlDecode( match.Groups["value"].Value.Trim() );
		if ( string.IsNullOrWhiteSpace( value ) )
			return [];

		try
		{
			var paths = JsonSerializer.Deserialize<List<string>>( value ) ?? [];
			return paths
				.Select( NormalizeFullPath )
				.Where( Directory.Exists )
				.Select( ConnecterRepository.FromPath )
				.ToList();
		}
		catch
		{
			return [];
		}
	}

	public static IReadOnlyList<ConnecterRepository> ReadRepositoriesFromDatabase( string databasePath )
	{
		if ( !File.Exists( databasePath ) )
			return [];

		// s&box does not ship a managed SQLite provider. Connecter stores repository
		// paths as plain text in default.dcdb, so this read-only scan is enough for
		// v1 root discovery without mutating or locking the workspace database.
		var bytes = File.ReadAllBytes( databasePath );
		var text = Encoding.UTF8.GetString( bytes );

		return WindowsPathRegex.Matches( text )
			.Select( x => NormalizeFullPath( x.Value.Trim().TrimEnd( '\\', '/', '\0' ) ) )
			.Where( Directory.Exists )
			.Select( ConnecterRepository.FromPath )
			.ToList();
	}

	private static IReadOnlyList<ConnecterRepository> DeduplicateRepositories( IEnumerable<ConnecterRepository> repositories )
	{
		var distinct = repositories
			.Where( x => !string.IsNullOrWhiteSpace( x.FullPath ) )
			.GroupBy( x => ConnecterPathUtility.NormalizeDirectoryPath( x.FullPath ), StringComparer.OrdinalIgnoreCase )
			.Select( x => x.First() )
			.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase )
			.ToList();

		return distinct
			.Where( repository => !distinct.Any( candidate =>
				!ReferenceEquals( candidate, repository )
				&& ConnecterPathUtility.IsPathInside( candidate.FullPath, repository.FullPath ) ) )
			.ToList();
	}

	private static string ResolveWorkspacePath( string requestedPath, bool allowAutoDiscover )
	{
		if ( !string.IsNullOrWhiteSpace( requestedPath ) )
			return NormalizeFullPath( requestedPath );

		var savedPath = GetSavedWorkspacePath();
		if ( IsConnecterWorkspacePath( savedPath ) )
			return NormalizeFullPath( savedPath );

		if ( allowAutoDiscover )
		{
			var discovered = DiscoverWorkspacePaths().FirstOrDefault();
			if ( !string.IsNullOrWhiteSpace( discovered ) )
				return discovered;
		}

		if ( !string.IsNullOrWhiteSpace( savedPath ) )
			return NormalizeFullPath( savedPath );

		return string.Empty;
	}

	private static IEnumerable<string> GetDefaultWorkspaceCandidates()
	{
		var candidates = new List<string>();

		AddIfNotEmpty( candidates, Environment.GetFolderPath( Environment.SpecialFolder.MyDocuments ), "Connecter" );
		AddIfNotEmpty( candidates, Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ), "Design Connected", "Connecter" );
		AddIfNotEmpty( candidates, Environment.GetFolderPath( Environment.SpecialFolder.LocalApplicationData ), "Design Connected", "Connecter" );
		AddIfNotEmpty( candidates, Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ), "Connecter" );
		AddIfNotEmpty( candidates, Environment.GetFolderPath( Environment.SpecialFolder.LocalApplicationData ), "Connecter" );

		foreach ( var drive in DriveInfo.GetDrives().Where( x => x.DriveType == DriveType.Fixed && x.IsReady ) )
		{
			candidates.Add( Path.Combine( drive.RootDirectory.FullName, "Game Assets", "Connecter" ) );
			candidates.Add( Path.Combine( drive.RootDirectory.FullName, "Connecter" ) );
		}

		return candidates;
	}

	private static void AddIfNotEmpty( List<string> candidates, string root, params string[] segments )
	{
		if ( string.IsNullOrWhiteSpace( root ) )
			return;

		var pathSegments = new string[segments.Length + 1];
		pathSegments[0] = root;
		Array.Copy( segments, 0, pathSegments, 1, segments.Length );
		candidates.Add( Path.Combine( pathSegments ) );
	}

	private static string GetSavedWorkspaceFilePath()
	{
		var appData = Environment.GetFolderPath( Environment.SpecialFolder.LocalApplicationData );
		if ( string.IsNullOrWhiteSpace( appData ) )
			appData = AppContext.BaseDirectory;

		return Path.Combine( appData, "sbox", "ConnecterBrowser", SavedWorkspaceFileName );
	}

	private static string NormalizeFullPath( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			return string.Empty;

		var trimmed = path.Trim().TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar );
		try
		{
			return Path.GetFullPath( trimmed );
		}
		catch
		{
			return trimmed;
		}
	}
}