Editor/McpExtras.Network.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

namespace Editor.Mcp;

public static partial class ExtrasTools
{
	private static NetworkToolLifetime NetworkLifetime = new();
	private static readonly Regex Steam2Pattern = new( @"STEAM_[0-5]:[01]:\d+", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );
	private static readonly Regex Steam3Pattern = new( @"\[[A-Za-z]:\d+:\d+\]", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );
	private static readonly Regex Steam64Pattern = new( @"(?<!\d)\d{17}(?!\d)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );

	/// <summary>Inspect the active network session without exposing account, party, chat, voice, or credential data.</summary>
	[McpTool.ReadOnly( "x_network_status" )]
	public static NetworkState GetNetworkStatus()
	{
		return SnapshotNetwork();
	}

	/// <summary>Start hosting through the editor's supported network path and return the observed state.</summary>
	[McpTool( "x_network_start_hosting" )]
	public static NetworkState StartNetworkHosting()
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		if ( EditorUtility.Network.Active || Networking.IsConnecting )
			throw new Exception( "Already connected or connecting; disconnect first." );

		try
		{
			EditorUtility.Network.StartHosting();
		}
		catch ( Exception )
		{
			throw new Exception( "Starting hosting failed; the editor reported an error and no other change was made." );
		}

		lifetime.ThrowIfInvalid();
		return SnapshotNetwork();
	}

	/// <summary>Disconnect the editor from its current network session without terminating owned child instances.</summary>
	[McpTool( "x_network_disconnect" )]
	public static NetworkState DisconnectNetwork()
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		if ( !EditorUtility.Network.Active && !Networking.IsConnecting )
			throw new Exception( "No network session is active." );

		try
		{
			EditorUtility.Network.Disconnect();
		}
		catch ( Exception )
		{
			throw new Exception( "Disconnecting failed; the editor reported an error and owned instances were left untouched." );
		}

		lifetime.ThrowIfInvalid();
		return SnapshotNetwork();
	}

	/// <summary>
	/// Launch a fixed local sbox client contained in a private process job. The argument list is fixed
	/// and deliberately does not copy arbitrary editor preference command-line arguments.
	/// </summary>
	[McpTool( "x_network_spawn_instance" )]
	public static InstanceState SpawnNetworkInstance( bool? windowed = null )
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		RequireStableHosting();
		var effectiveWindowed = windowed ?? EditorPreferences.WindowedLocalInstances;

		OwnedInstanceSnapshot snapshot;
		try
		{
			snapshot = lifetime.Registry.LaunchSbox( effectiveWindowed, IsSyntheticIdentityVisible );
		}
		catch ( OwnedInstanceException ex )
		{
			throw TranslateLaunchFailure( ex );
		}
		catch ( Exception )
		{
			throw new Exception( "Local instance launch setup failed; no process was started." );
		}

		lifetime.ThrowIfInvalid();
		return MapInstance( snapshot );
	}

	/// <summary>List only this hotload lifetime's in-memory owned process jobs; never enumerate OS processes.</summary>
	[McpTool.ReadOnly( "x_network_instances" )]
	public static InstanceState[] GetNetworkInstances()
	{
		var lifetime = CurrentLifetime();
		try
		{
			return lifetime.Registry.List( !lifetime.IsBusy ).Select( MapInstance ).ToArray();
		}
		catch ( OwnedInstanceException )
		{
			throw new Exception( "Owned instance inspection failed." );
		}
	}

	/// <summary>
	/// Refuse local-client host migration before launching anything. Installed engine 26.09.08e
	/// chooses successors from Steam lobby membership, which synthetic loopback clients cannot join,
	/// and exposes no supported API that can target or certify a local successor.
	/// </summary>
	[McpTool( "x_network_migrate_to_new_instance" )]
	public static Task<NetworkState> MigrateNetworkToNewInstance( bool? windowed = null, int timeoutSeconds = 120 )
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		if ( timeoutSeconds < 1 || timeoutSeconds > 120 )
			throw new Exception( "timeoutSeconds must be between 1 and 120." );
		if ( !IsStableHosting() || GetRemoteConnections().Length != 0 )
			throw new Exception( "Migration requires a hosting session with no remote peers." );

		throw new Exception( "The installed engine cannot migrate hosting to a synthetic local client; no process was launched and the editor remains connected." );
	}

	/// <summary>
	/// Terminate exactly one currently owned process job. Both the PID and the exact creation-time
	/// LaunchedAt token returned by spawn or listing are required. Retained cleanup authority stays
	/// internal until that exact identity is readable. Abrupt skips the two-second graceful close budget.
	/// </summary>
	[McpTool( "x_network_terminate_instance" )]
	public static async Task<InstanceTermination> TerminateNetworkInstance( int processId, string launchTimestamp, bool abrupt = false )
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		try
		{
			var outcome = await lifetime.Registry.TerminateAsync( processId, launchTimestamp, abrupt, lifetime.CancellationToken );
			lifetime.ThrowIfInvalid();
			return new InstanceTermination
			{
				ProcessId = outcome.ProcessId,
				Result = outcome.Result,
				Forced = outcome.Forced,
				Message = outcome.Message
			};
		}
		catch ( OperationCanceledException ) when ( !lifetime.IsValid )
		{
			throw new Exception( NetworkToolLifetime.InvalidatedMessage );
		}
		catch ( OwnedInstanceException ex )
		{
			var native = ex.NativeError > 0 ? $"; native error {ex.NativeError}" : string.Empty;
			throw new Exception( $"{ex.Message} PID {processId}{native}. Ownership is retained; the instance stays listed." );
		}
	}

	/// <summary>A privacy-limited snapshot of the active editor network session.</summary>
	public class NetworkState
	{
		public bool IsActive { get; set; }
		public bool IsHost { get; set; }
		public bool IsClient { get; set; }
		public bool IsConnecting { get; set; }
		public Guid? LocalConnectionId { get; set; }
		public Guid? HostConnectionId { get; set; }
		public ConnectionState[] Connections { get; set; }
	}

	/// <summary>A visible connection with account and party identifiers deliberately omitted.</summary>
	public class ConnectionState
	{
		public Guid Id { get; set; }
		public string DisplayName { get; set; }
		public bool IsHost { get; set; }
		public bool IsLocal { get; set; }
		public bool IsActive { get; set; }
		public bool IsConnecting { get; set; }
		public float Ping { get; set; }
	}

	/// <summary>
	/// An in-memory owned local process descriptor. LaunchedAt is the exact UTC process creation time
	/// and its termination token; unidentified cleanup authority is not exposed until this is readable.
	/// </summary>
	public class InstanceState
	{
		public int ProcessId { get; set; }
		public string LaunchedAt { get; set; }
		public bool Windowed { get; set; }
		public string LogDirectory { get; set; }
		public bool Alive { get; set; }
		public bool Exited { get; set; }
		public int ActiveProcessCount { get; set; }
	}

	/// <summary>The bounded result of an ownership-checked termination request.</summary>
	public class InstanceTermination
	{
		public int ProcessId { get; set; }
		public string Result { get; set; }
		public bool Forced { get; set; }
		public string Message { get; set; }
	}

	private static NetworkToolLifetime CurrentLifetime()
	{
		var lifetime = NetworkLifetime;
		lifetime.ThrowIfInvalid();
		return lifetime;
	}

	private static void RequireStableHosting()
	{
		if ( !IsStableHosting() ) throw new Exception( "Editor must be hosting first." );
	}

	private static bool IsStableHosting()
	{
		return EditorUtility.Network.Hosting && Networking.IsActive && !Networking.IsConnecting;
	}

	private static NetworkState SnapshotNetwork()
	{
		var active = Networking.IsActive;
		if ( !active )
		{
			return new NetworkState
			{
				IsActive = false,
				IsHost = false,
				IsClient = false,
				IsConnecting = Networking.IsConnecting,
				LocalConnectionId = null,
				HostConnectionId = null,
				Connections = Array.Empty<ConnectionState>()
			};
		}

		var local = Connection.Local;
		var host = Connection.Host;
		var localId = local?.Id;
		var hostId = host?.Id;
		var connections = Connection.All
			.Where( connection => connection is not null )
			.Select( connection => new ConnectionState
			{
				Id = connection.Id,
				DisplayName = SanitizeDisplayName( connection.DisplayName ),
				IsHost = hostId.HasValue && connection.Id == hostId.Value,
				IsLocal = localId.HasValue && connection.Id == localId.Value,
				IsActive = connection.IsActive,
				IsConnecting = connection.IsConnecting,
				Ping = connection.Ping
			})
			.ToArray();

		return new NetworkState
		{
			IsActive = true,
			IsHost = Networking.IsHost,
			IsClient = Networking.IsClient,
			IsConnecting = Networking.IsConnecting,
			LocalConnectionId = localId,
			HostConnectionId = hostId,
			Connections = connections
		};
	}

	private static Connection[] GetRemoteConnections()
	{
		var localId = Connection.Local?.Id;
		return Connection.All
			.Where( connection => connection is not null && (!localId.HasValue || connection.Id != localId.Value) )
			.ToArray();
	}

	private static bool IsSyntheticIdentityVisible( ulong identity )
	{
		return Connection.All.Any( connection => connection is not null && connection.SteamId.ValueUnsigned == identity );
	}

	private static string SanitizeDisplayName( string value )
	{
		if ( string.IsNullOrEmpty( value ) ) return string.Empty;
		var clean = new StringBuilder( Math.Min( value.Length, 512 ) );
		foreach ( var character in value )
		{
			if ( clean.Length == 512 ) break;
			if ( !char.IsControl( character ) ) clean.Append( character );
		}

		var result = Steam2Pattern.Replace( clean.ToString(), "[redacted]" );
		result = Steam3Pattern.Replace( result, "[redacted]" );
		result = Steam64Pattern.Replace( result, "[redacted]" );
		return result.Length <= 128 ? result : result[..128];
	}

	private static InstanceState MapInstance( OwnedInstanceSnapshot snapshot )
	{
		return new InstanceState
		{
			ProcessId = snapshot.ProcessId,
			LaunchedAt = snapshot.LaunchedAt,
			Windowed = snapshot.Windowed,
			LogDirectory = snapshot.LogDirectory,
			Alive = snapshot.Alive,
			Exited = snapshot.Exited,
			ActiveProcessCount = snapshot.ActiveProcessCount
		};
	}

	private static Exception TranslateLaunchFailure( OwnedInstanceException exception )
	{
		var process = exception.ProcessId > 0 ? $" PID {exception.ProcessId}." : string.Empty;
		var native = exception.NativeError > 0 ? $" Native error {exception.NativeError}." : string.Empty;
		return new Exception( exception.Message + process + native );
	}

}

internal sealed class NetworkToolLifetime : IHotloadManaged
{
	internal const string BusyMessage = "Another network operation is in progress; wait for it to finish.";
	internal const string InvalidatedMessage = "The network tools were invalidated by a library hotload; retry the operation.";

	private OwnedInstanceRegistry _registry = new();
	private CancellationTokenSource _cancellation = new();
	private long _busy;
	private int _valid = 1;

	// Null only after invalidation released the handles; callers report the fixed invalidation error.
	internal OwnedInstanceRegistry Registry
	{
		get
		{
			var registry = Volatile.Read( ref _registry );
			if ( registry is null ) throw new Exception( NetworkToolLifetime.InvalidatedMessage );
			return registry;
		}
	}

	internal CancellationToken CancellationToken => _cancellation.Token;
	internal bool IsBusy => Volatile.Read( ref _busy ) != 0;
	internal bool IsValid => Volatile.Read( ref _valid ) != 0;

	internal IDisposable EnterMutation()
	{
		ThrowIfInvalid();
		if ( Interlocked.CompareExchange( ref _busy, 1, 0 ) != 0 )
			throw new Exception( BusyMessage );
		if ( !IsValid )
		{
			Volatile.Write( ref _busy, 0 );
			throw new Exception( NetworkToolLifetime.InvalidatedMessage );
		}
		return new MutationLease( this );
	}

	internal void ThrowIfInvalid()
	{
		if ( !IsValid ) throw new Exception( NetworkToolLifetime.InvalidatedMessage );
	}

	public void Destroyed( Dictionary<string, object> state ) => Invalidate();

	// Hotload state is deliberately not transferred: surviving children cease to be owned.

	public void Created( IReadOnlyDictionary<string, object> state )
	{
		_registry = new OwnedInstanceRegistry();
		_cancellation = new CancellationTokenSource();
		Volatile.Write( ref _busy, 0 );
		Volatile.Write( ref _valid, 1 );
	}

	public void Persisted() { }
	public void Failed() => Invalidate();

	private void Invalidate()
	{
		Volatile.Write( ref _valid, 0 );
		_cancellation.Cancel();

		// No new mutation can start once invalid, so the only reason to wait is an operation
		// still holding retained handles. Closing them never terminates a process.
		ReleaseHandlesWhenIdle();
	}

	private void ReleaseHandlesWhenIdle()
	{
		if ( Volatile.Read( ref _busy ) != 0 ) return;
		Interlocked.Exchange( ref _registry, null )?.Dispose();
	}

	private sealed class MutationLease : IDisposable
	{
		private NetworkToolLifetime _owner;
		internal MutationLease( NetworkToolLifetime owner ) => _owner = owner;
		public void Dispose()
		{
			var owner = Interlocked.Exchange( ref _owner, null );
			if ( owner is null ) return;
			Volatile.Write( ref owner._busy, 0 );
			if ( !owner.IsValid ) owner.ReleaseHandlesWhenIdle();
		}
	}
}