Editor/Prism/Ui/Adapters/PrismGraphAdapter.cs

Adapter that wraps a PrismGraph document to implement the IGraph interface used by a node-graph UI. It creates and caches PrismNodeAdapter instances, routes document mutations through GraphMutations, manages detached input plugs during drag/drop, computes reachability for drawing, and attaches diagnostics to nodes.

File AccessReflection
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using Editor.Prism.Undo;

namespace Editor.Prism.Ui.Adapters;

/// <summary>
/// A <see cref="PrismGraph"/> document, as the node-graph framework sees it.
/// <para>
/// <c>IGraph</c> is five members wide, and three of them are copy/paste. Mapping them onto Prism's own
/// serializer rather than inventing a second format is what makes the framework's clipboard, duplicate
/// and drag-drop paths produce documents that are byte-identical to what a save would have written —
/// including stable ids, unknown-node round-tripping and per-node fault isolation.
/// </para>
/// <para>
/// Everything that mutates goes through <see cref="GraphMutations"/> so that the framework's own undo
/// bracketing (<c>PushUndo</c>/<c>PushRedo</c>) and ours are the same stack rather than two stacks that
/// disagree.
/// </para>
/// </summary>
public sealed class PrismGraphAdapter : IGraph
{
	readonly Dictionary<NodeId, PrismNodeAdapter> _adapters = new();
	readonly List<PrismPlugIn> _detached = new();

	IReadOnlyCollection<NodeId> _reachable;
	int _reachableVersion = -1;
	int _topologyVersion;

	/// <summary>Wrap a document for the canvas.</summary>
	public PrismGraphAdapter( PrismGraph document, GraphMutations mutations = null )
	{
		Document = document;
		Mutations = mutations ?? new GraphMutations( document );

		if ( Document is null ) return;

		Document.NodeRemoved += OnNodeRemoved;

		// Reachability only changes when the topology does. Recomputing it against the document's
		// version counter instead would re-walk the whole graph on every frame of a node drag.
		Document.NodeAdded += OnTopologyChanged;
		Document.NodeRemoved += OnTopologyChanged;
		Document.EdgeAdded += OnTopologyChanged;
		Document.EdgeRemoved += OnTopologyChanged;
	}

	/// <summary>
	/// Bumped whenever a node or an edge is added or removed. Anything that caches a projection of the
	/// graph's shape — reachability, the minimap model — compares this instead of rebuilding on a timer.
	/// </summary>
	public int TopologyVersion => _topologyVersion;

	/// <summary>Stop listening to the document. Call when the canvas is torn down.</summary>
	public void Dispose()
	{
		if ( Document is null ) return;

		Document.NodeRemoved -= OnNodeRemoved;
		Document.NodeAdded -= OnTopologyChanged;
		Document.NodeRemoved -= OnTopologyChanged;
		Document.EdgeAdded -= OnTopologyChanged;
		Document.EdgeRemoved -= OnTopologyChanged;

		Clear();
	}

	/// <summary>The document being edited.</summary>
	public PrismGraph Document { get; }

	/// <summary>The only sanctioned way to change the document.</summary>
	public GraphMutations Mutations { get; set; }

	/// <summary>Where problems raised while adapting are reported. Optional.</summary>
	public DiagnosticSink Diagnostics { get; set; }

	/// <summary>Raised after nodes arrive from the clipboard, so the view can frame them.</summary>
	public event Action<IReadOnlyList<PrismNode>> NodesPasted;

	/// <inheritdoc/>
	public IEnumerable<INode> Nodes
	{
		get
		{
			if ( Document is null ) yield break;

			foreach ( var node in Document.Nodes )
			{
				var adapter = Wrap( node );

				if ( adapter is not null ) yield return adapter;
			}
		}
	}

	/// <summary>Every node adapter currently alive, in document order.</summary>
	public IEnumerable<PrismNodeAdapter> NodeAdapters => Nodes.OfType<PrismNodeAdapter>();

	/// <summary>Input plugs the framework believes are disconnected while the document still is not.</summary>
	public IReadOnlyList<PrismPlugIn> DetachedPlugs => _detached;

	/// <summary>True while at least one wire has been picked up but not yet dropped.</summary>
	public bool HasPendingDetach => _detached.Count > 0;

	// ---------------------------------------------------------------- IGraph ----

	/// <inheritdoc/>
	public void AddNode( INode node )
	{
		if ( node is not PrismNodeAdapter adapter || Document is null ) return;

		var model = adapter.PrismNode;

		if ( model is null || Document.FindNode( model.Id ) is not null ) return;

		if ( Mutations is not null ) Mutations.AddNode( model, model.Position );
		else Document.AddNode( model );

		_adapters[model.Id] = adapter;
	}

	/// <inheritdoc/>
	public void RemoveNode( INode node )
	{
		if ( node is not PrismNodeAdapter adapter || Document is null ) return;

		var model = adapter.PrismNode;

		if ( model is null ) return;

		if ( Document.FindNode( model.Id ) is null )
		{
			// The drag-and-drop ghost is created but never added; removing it must not be an error.
			Forget( model.Id );
			return;
		}

		if ( Mutations is not null ) Mutations.RemoveNode( model.Id );
		else Document.RemoveNode( model.Id );

		Forget( model.Id );
	}

	/// <inheritdoc/>
	public string SerializeNodes( IEnumerable<INode> nodes )
	{
		if ( Document is null || nodes is null ) return "{}";

		var models = nodes
			.OfType<PrismNodeAdapter>()
			.Select( x => x.PrismNode )
			.Where( x => x is not null )
			.ToArray();

		return PrismLog.Guard( "Serialize nodes", () => PrismSerializer.WriteNodes( Document, models ), "{}" );
	}

	/// <inheritdoc/>
	public IEnumerable<INode> DeserializeNodes( string serialized )
	{
		if ( Document is null || string.IsNullOrWhiteSpace( serialized ) ) return Array.Empty<INode>();

		var added = PrismLog.Guard( "Deserialize nodes",
			() => PrismSerializer.ReadNodes( Document, serialized, true, Diagnostics ),
			Array.Empty<PrismNode>() );

		if ( added is null || added.Count == 0 ) return Array.Empty<INode>();

		PrismLog.Guard( "Nodes pasted", () => NodesPasted?.Invoke( added ) );

		return added.Select( Wrap ).Where( x => x is not null ).Cast<INode>().ToArray();
	}

	// ---------------------------------------------------------------- lookups ----

	/// <summary>
	/// The adapter for a node, creating it if this is the first time we have seen it. Exactly one
	/// adapter exists per node, because the framework compares plugs and nodes by reference.
	/// </summary>
	public PrismNodeAdapter Wrap( PrismNode node )
	{
		if ( node is null ) return null;

		if ( _adapters.TryGetValue( node.Id, out var existing ) )
		{
			// An undo restore can replace a node object while keeping its id. Reusing the stale adapter
			// would leave the card pointing at a node the document no longer contains.
			if ( ReferenceEquals( existing.PrismNode, node ) ) return existing;

			existing.Detach();
		}

		var created = Create( node );

		_adapters[node.Id] = created;

		return created;
	}

	/// <summary>The adapter for a node id, or null when there is no such node.</summary>
	public PrismNodeAdapter Find( NodeId id )
	{
		var node = Document?.FindNode( id );

		return node is null ? null : Wrap( node );
	}

	/// <summary>The input plug adapter a port reference points at, or null.</summary>
	public PrismPlugIn FindPlugIn( PortRef reference ) => Find( reference.Node )?.FindInput( reference.Port );

	/// <summary>The output plug adapter a port reference points at, or null.</summary>
	public PrismPlugOut FindPlugOut( PortRef reference ) => Find( reference.Node )?.FindOutput( reference.Port );

	/// <summary>Drop the cached adapter for a node id.</summary>
	public void Forget( NodeId id )
	{
		if ( !_adapters.Remove( id, out var adapter ) ) return;

		adapter.Detach();

		for ( int i = _detached.Count - 1; i >= 0; i-- )
		{
			if ( _detached[i].Owner == adapter ) _detached.RemoveAt( i );
		}
	}

	/// <summary>Drop every cached adapter. Called when the document is reloaded wholesale.</summary>
	public void Clear()
	{
		foreach ( var adapter in _adapters.Values ) adapter.Detach();

		_adapters.Clear();
		_detached.Clear();
		_reachableVersion = -1;
	}

	// ---------------------------------------------------------------- state ----

	/// <summary>
	/// True when a node contributes to something the shader actually outputs. Unreachable nodes are
	/// drawn dimmed, which is the cheapest possible answer to "why is my change doing nothing?".
	/// </summary>
	public bool IsReachable( PrismNode node )
	{
		if ( node is null || Document is null ) return true;

		if ( _reachable is null || _reachableVersion != _topologyVersion )
		{
			_reachable = PrismLog.Guard<IReadOnlyCollection<NodeId>>( "Reachability",
				() => GraphQueries.ReachableFromOutputs( Document ), null );

			_reachableVersion = _topologyVersion;
		}

		// A document with no output node has no reachable set at all; dimming every single card in it
		// would be technically true and completely useless.
		if ( _reachable is null || _reachable.Count == 0 ) return true;

		return _reachable.Contains( node.Id );
	}

	/// <summary>Attach compiler diagnostics to the nodes and ports they name.</summary>
	public void ApplyDiagnostics( IEnumerable<Diagnostic> diagnostics )
	{
		var byNode = new Dictionary<NodeId, List<Diagnostic>>();

		if ( diagnostics is not null )
		{
			foreach ( var diagnostic in diagnostics )
			{
				if ( diagnostic?.Graph is not { } graph || !graph.Node.IsValid ) continue;

				if ( !byNode.TryGetValue( graph.Node, out var list ) )
				{
					byNode[graph.Node] = list = new List<Diagnostic>();
				}

				list.Add( diagnostic );
			}
		}

		foreach ( var adapter in _adapters.Values )
		{
			var id = adapter.PrismNode?.Id ?? default;

			adapter.SetDiagnostics( byNode.TryGetValue( id, out var list ) ? list : null );
		}
	}

	/// <summary>Record that a wire has been picked up off an input but not yet dropped anywhere.</summary>
	public void NoteDetached( PrismPlugIn plug )
	{
		if ( plug is null || _detached.Contains( plug ) ) return;

		_detached.Add( plug );
	}

	/// <summary>
	/// Resolve every pending detach. A wire that was dropped somewhere has already resolved itself
	/// through the connection setter or through connection removal; anything still pending here was
	/// dropped back where it came from, so the document is left exactly as it was.
	/// </summary>
	public void ResolveDetached()
	{
		if ( _detached.Count == 0 ) return;

		var pending = _detached.ToArray();

		_detached.Clear();

		foreach ( var plug in pending )
		{
			plug.CancelDetach();
		}
	}

	/// <summary>Commit a specific pending detach, because the wire really was removed.</summary>
	public void CommitDetached( PrismPlugIn plug )
	{
		if ( plug is null ) return;

		_detached.Remove( plug );
		plug.CommitDetach();
	}

	PrismNodeAdapter Create( PrismNode node )
	{
		var id = node.Descriptor?.Id;

		if ( string.Equals( id, GraphMutations.RerouteTypeId, StringComparison.Ordinal ) )
			return new PrismRerouteAdapter( this, node );

		if ( string.Equals( id, PrismCommentAdapter.TypeId, StringComparison.Ordinal ) )
			return new PrismCommentAdapter( this, node );

		return new PrismNodeAdapter( this, node );
	}

	void OnNodeRemoved( PrismNode node )
	{
		if ( node is null ) return;

		Forget( node.Id );
	}

	void OnTopologyChanged( PrismNode node ) => _topologyVersion++;

	void OnTopologyChanged( Edge edge ) => _topologyVersion++;
}