Editor/Prism/Nodes/SubgraphNodes.cs

Editor code that implements subgraph (prismfn) support for the Prism shader graph editor. It defines port metadata (SubgraphPortInfo), caching and loading of .prismfn documents (SubgraphLibrary), thread-local argument binding for inlined instances (SubgraphBindings), IR renaming/rewriting to avoid local name collisions (IrRewriter), and node types: SubgraphInputNode, SubgraphOutputNode and SubgraphInstanceNode which handle port definition, validation, and inlining/compilation of referenced subgraphs into the caller's IR.

File AccessReflection
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;

namespace Editor.Prism.Nodes;

// ---------------------------------------------------------------------------------------------------
// Subgraphs.
//
// A .prismfn is a pure function: typed inputs in, typed outputs out. An instance of one is inlined at
// emit time — the referenced document is compiled through its own NodeEmitter against the *same*
// IrModule, and the statements it produced are spliced into the caller. That keeps one module, one
// helper table, one global table and one interpolator budget, which is what makes a subgraph free
// rather than a function call the driver has to inline anyway.
//
// The two things that make splicing safe are done here: every local the subgraph declared is renamed
// with a per-instance prefix so nothing shadows a caller's temp, and a thread-local binding frame
// carries the caller's argument values down to the SubgraphInput nodes.
// ---------------------------------------------------------------------------------------------------

/// <summary>
/// One typed slot on a node's boundary: an input or output of a subgraph, or one row of a
/// custom-code node's port table. Authored data, so every field is mutable and every field is
/// serialized.
/// </summary>
public sealed class SubgraphPortInfo
{
	/// <summary>The slot's authored name. Also the port id, after sanitizing.</summary>
	public string Name { get; set; } = "Value";

	/// <summary>The slot's declared type, spelled the way HLSL spells it.</summary>
	public string Type { get; set; } = "float";

	/// <summary>What the slot is for. Becomes the port's tooltip.</summary>
	public string Description { get; set; }

	/// <summary>Sort order on the instance's card.</summary>
	public int Order { get; set; }

	/// <summary>True when leaving the slot unconnected is an error.</summary>
	public bool Required { get; set; }

	/// <summary>The slot's default value, packed into up to four components.</summary>
	public float[] Default { get; set; }

	/// <summary>The port id this slot occupies.</summary>
	[JsonIgnore] public string PortId => SubgraphLibrary.Identifier( Name );

	/// <summary>The slot's resolved type, falling back to <c>float</c>.</summary>
	[JsonIgnore] public ShaderType ResolvedType =>
		ShaderType.TryParse( Type, out var type ) && !type.IsVoid ? type : ShaderType.Float;

	/// <summary>The default value as the compiler's literal shape.</summary>
	[JsonIgnore] public ConstValue DefaultValue
	{
		get
		{
			var v = Default ?? Array.Empty<float>();

			return new ConstValue(
				v.Length > 0 ? v[0] : 0d,
				v.Length > 1 ? v[1] : 0d,
				v.Length > 2 ? v[2] : 0d,
				v.Length > 3 ? v[3] : 0d );
		}
	}

	/// <summary>Copy this slot.</summary>
	public SubgraphPortInfo Clone() => new()
	{
		Name = Name,
		Type = Type,
		Description = Description,
		Order = Order,
		Required = Required,
		Default = Default is null ? null : (float[])Default.Clone()
	};

	/// <inheritdoc/>
	public override string ToString() => $"{Name} : {Type}";
}

/// <summary>
/// Loads and caches <c>.prismfn</c> documents, and describes their boundary.
/// </summary>
public static class SubgraphLibrary
{
	sealed class Entry
	{
		public PrismGraph Graph;
		public string Error;
		public DateTime LoadedUtc;

		/// <summary>
		/// True for a document published through <see cref="Register"/> rather than read from disk.
		/// <see cref="Invalidate"/> leaves these alone: an unsaved subgraph has no file to fall back to,
		/// so dropping it would turn every instance of it into a broken node.
		/// </summary>
		public bool Pinned;
	}

	static readonly Dictionary<string, Entry> s_cache = new( StringComparer.OrdinalIgnoreCase );
	static readonly object s_lock = new();

	/// <summary>Drop every cached document. Must run on hotload and whenever a subgraph is saved.</summary>
	public static void Flush()
	{
		lock ( s_lock )
		{
			s_cache.Clear();
		}
	}

	/// <summary>
	/// Publish an in-memory document under a path, so instances of it see the live version rather than
	/// what is on disk.
	/// <para>
	/// This is how an open subgraph window keeps every instance of itself up to date while it is being
	/// edited — and how a subgraph can be inlined at all before it has ever been saved. Pass null to
	/// go back to reading the file.
	/// </para>
	/// </summary>
	public static void Register( string path, PrismGraph graph )
	{
		if ( string.IsNullOrWhiteSpace( path ) ) return;

		var key = Normalize( path );

		if ( graph is null )
		{
			Unregister( key );
			return;
		}

		lock ( s_lock )
		{
			s_cache[key] = new Entry { Graph = graph, LoadedUtc = DateTime.UtcNow, Pinned = true };
		}
	}

	/// <summary>
	/// Drop one cached document, so the next instance that asks for it reloads from disk. A document
	/// published through <see cref="Register"/> is left alone — it may never have been saved, so there
	/// would be nothing to reload — use <see cref="Unregister"/> to drop one of those.
	/// </summary>
	public static void Invalidate( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) ) return;

		lock ( s_lock )
		{
			if ( s_cache.TryGetValue( Normalize( path ), out var entry ) && entry.Pinned ) return;

			s_cache.Remove( Normalize( path ) );
		}
	}

	/// <summary>Drop a cached document even when it was published in memory. Call this when the window
	/// that published it closes, so instances go back to reading the file.</summary>
	public static void Unregister( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) ) return;

		lock ( s_lock )
		{
			s_cache.Remove( Normalize( path ) );
		}
	}

	/// <summary>
	/// The document at a content-relative path, or null with a reason. Never throws, and never reads
	/// the same file twice in a session unless it was invalidated.
	/// </summary>
	public static PrismGraph Load( string path, out string error )
	{
		error = null;

		if ( string.IsNullOrWhiteSpace( path ) )
		{
			error = "No subgraph is selected";
			return null;
		}

		var key = Normalize( path );

		lock ( s_lock )
		{
			if ( s_cache.TryGetValue( key, out var cached ) )
			{
				error = cached.Error;
				return cached.Graph;
			}
		}

		var entry = Read( key );

		lock ( s_lock )
		{
			s_cache[key] = entry;
		}

		error = entry.Error;
		return entry.Graph;
	}

	static Entry Read( string path )
	{
		var entry = new Entry { LoadedUtc = DateTime.UtcNow };

		var text = PrismLog.Guard<string>( $"Read subgraph '{path}'", () =>
		{
			var content = Editor.FileSystem.Content;

			if ( content is null ) return null;
			if ( !content.FileExists( path ) ) return null;

			return content.ReadAllText( path );
		} );

		if ( string.IsNullOrWhiteSpace( text ) )
		{
			entry.Error = $"Cannot find a subgraph at \"{path}\"";
			return entry;
		}

		var sink = new DiagnosticSink();
		var graph = PrismLog.Guard<PrismGraph>( $"Parse subgraph '{path}'", () => PrismSerializer.Read( text, sink ) );

		if ( graph is null )
		{
			entry.Error = $"\"{path}\" is not a readable Prism document";
			return entry;
		}

		graph.AssetPath = path;
		entry.Graph = graph;

		if ( sink.HasErrors )
		{
			entry.Error = $"\"{path}\" loaded with errors: " +
				string.Join( "; ", sink.All.Where( x => x.Severity == DiagnosticSeverity.Error )
					.Select( x => x.Message ).Take( 3 ) );
		}

		return entry;
	}

	static string Normalize( string path ) => path.Replace( '\\', '/' ).Trim();

	/// <summary>The terminal node of a subgraph document, if it declares one.</summary>
	public static SubgraphOutputNode Terminal( PrismGraph graph )
	{
		if ( graph?.Nodes is null ) return null;

		foreach ( var node in graph.Nodes )
		{
			if ( node is SubgraphOutputNode terminal ) return terminal;
		}

		return null;
	}

	/// <summary>The input slots a document exposes, in authored order.</summary>
	public static List<SubgraphPortInfo> DescribeInputs( PrismGraph graph )
	{
		var slots = new List<SubgraphPortInfo>();

		if ( graph?.Nodes is null ) return slots;

		var found = graph.Nodes.OfType<SubgraphInputNode>()
			.Where( x => !string.IsNullOrWhiteSpace( x.InputName ) )
			.OrderBy( x => x.PortOrder )
			.ThenBy( x => x.InputName, StringComparer.OrdinalIgnoreCase );

		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		foreach ( var input in found )
		{
			if ( !seen.Add( Identifier( input.InputName ) ) ) continue;

			slots.Add( input.Describe() );
		}

		return slots;
	}

	/// <summary>The output slots a document exposes, in authored order.</summary>
	public static List<SubgraphPortInfo> DescribeOutputs( PrismGraph graph )
	{
		var terminal = Terminal( graph );

		if ( terminal?.Slots is null ) return new List<SubgraphPortInfo>();

		return terminal.Slots
			.Where( x => x is not null && !string.IsNullOrWhiteSpace( x.Name ) )
			.OrderBy( x => x.Order )
			.Select( x => x.Clone() )
			.ToList();
	}

	/// <summary>
	/// Turn an authored slot name into a legal, stable port id.
	/// <para>
	/// ASCII only. A subgraph port id becomes a parameter name inside the generated helper function, and
	/// the engine's <c>.shader</c> front-end is an ANTLR grammar over ASCII: one accented letter fails the
	/// whole file with a mismatched-token dump and no line number at all. <c>char.IsLetterOrDigit</c> is
	/// Unicode-aware and would let one straight through, so the ASCII-specific overloads are used.
	/// </para>
	/// </summary>
	public static string Identifier( string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) ) return "Value";

		var text = new System.Text.StringBuilder( name.Length );

		foreach ( var c in name )
		{
			if ( char.IsAsciiLetterOrDigit( c ) ) text.Append( c );
			else if ( text.Length > 0 && text[^1] != '_' ) text.Append( '_' );
		}

		var result = text.ToString().Trim( '_' );

		if ( result.Length == 0 ) return "Value";
		if ( char.IsAsciiDigit( result[0] ) ) result = "_" + result;

		return result;
	}
}

/// <summary>
/// The argument values a subgraph instance passes down to the <see cref="SubgraphInputNode"/>s of the
/// document it is inlining.
/// <para>
/// Thread-static and stack-shaped, because a subgraph can instance another subgraph. A frame is pushed
/// only after the caller has finished reading its own inputs, so an outer input demanded on behalf of
/// an inner instance still resolves against the outer frame.
/// </para>
/// </summary>
internal static class SubgraphBindings
{
	sealed class Frame : IDisposable
	{
		public Dictionary<string, IrValue> Values;
		public string Path;

		public void Dispose()
		{
			if ( s_stack is null || s_stack.Count == 0 ) return;

			s_stack.RemoveAt( s_stack.Count - 1 );
		}
	}

	[ThreadStatic] static List<Frame> s_stack;

	/// <summary>Push a frame of argument values. Dispose to pop it.</summary>
	public static IDisposable Push( string path, Dictionary<string, IrValue> values )
	{
		s_stack ??= new List<Frame>();

		var frame = new Frame { Values = values, Path = path };

		s_stack.Add( frame );

		return frame;
	}

	/// <summary>The value bound to an input name in the innermost frame.</summary>
	public static bool TryGet( string name, out IrValue value )
	{
		value = IrValue.Invalid;

		if ( s_stack is null || s_stack.Count == 0 || string.IsNullOrEmpty( name ) ) return false;

		var values = s_stack[^1].Values;

		return values is not null && values.TryGetValue( name, out value ) && value.IsValid;
	}

	/// <summary>True when this document is already being inlined further up the stack.</summary>
	public static bool IsActive( string path )
	{
		if ( s_stack is null || string.IsNullOrWhiteSpace( path ) ) return false;

		foreach ( var frame in s_stack )
		{
			if ( string.Equals( frame.Path, path, StringComparison.OrdinalIgnoreCase ) ) return true;
		}

		return false;
	}

	/// <summary>How deep the inlining stack currently is.</summary>
	public static int Depth => s_stack?.Count ?? 0;
}

/// <summary>
/// Renames the locals a spliced block declared, so an inlined subgraph can never shadow a temp the
/// caller is still using.
/// </summary>
internal static class IrRewriter
{
	/// <summary>
	/// The rename map for every local declared across a set of blocks.
	/// <para>
	/// Built across all of them at once, and applied only afterwards, because a value produced in one
	/// block can be referenced from another and both have to agree on the new name.
	/// </para>
	/// </summary>
	public static Dictionary<string, string> BuildMap( IEnumerable<IrBlock> blocks, string prefix )
	{
		var map = new Dictionary<string, string>( StringComparer.Ordinal );

		if ( blocks is null || string.IsNullOrEmpty( prefix ) ) return map;

		var declared = new HashSet<string>( StringComparer.Ordinal );

		foreach ( var block in blocks ) Collect( block, declared );

		foreach ( var name in declared ) map[name] = prefix + name;

		return map;
	}

	/// <summary>Rewrite a value produced inside a renamed block so it still names the right local.</summary>
	public static IrValue Rewrite( IrValue value, Dictionary<string, string> map )
	{
		if ( !value.IsValid || map is null || map.Count == 0 ) return value;

		var rewritten = Rewrite( value.Expr, map );

		return ReferenceEquals( rewritten, value.Expr ) ? value : new IrValue( rewritten, value.Type );
	}

	static void Collect( IrBlock block, HashSet<string> declared )
	{
		if ( block is null ) return;

		foreach ( var statement in block.Statements )
		{
			switch ( statement )
			{
				case IrDecl decl when !string.IsNullOrEmpty( decl.Name ):
					declared.Add( decl.Name );
					break;

				case IrFor loop when !string.IsNullOrEmpty( loop.Var ):
					declared.Add( loop.Var );
					break;
			}

			foreach ( var child in Children( statement ) ) Collect( child, declared );
		}
	}

	/// <summary>Apply a rename map to every statement in a block, recursively.</summary>
	public static void Apply( IrBlock block, Dictionary<string, string> map )
	{
		if ( block is null || map is null || map.Count == 0 ) return;

		for ( int i = 0; i < block.Statements.Count; i++ )
		{
			block.Statements[i] = Rewrite( block.Statements[i], map );
		}
	}

	static IrStmt Rewrite( IrStmt statement, Dictionary<string, string> map )
	{
		switch ( statement )
		{
			case IrDecl decl:
				return decl with { Name = Rename( decl.Name, map ), Init = Rewrite( decl.Init, map ) };

			case IrAssign assign:
				return assign with
				{
					Target = Rewrite( assign.Target, map ),
					Value = Rewrite( assign.Value, map )
				};

			case IrIf branch:
				Apply( branch.Then, map );
				Apply( branch.Else, map );
				return branch with { Cond = Rewrite( branch.Cond, map ) };

			case IrFor loop:
				Apply( loop.Body, map );
				return loop with { Var = Rename( loop.Var, map ), Count = Rewrite( loop.Count, map ) };

			case IrWhile loop:
				Apply( loop.Body, map );
				return loop with { Cond = Rewrite( loop.Cond, map ) };

			case IrReturn ret:
				return ret with { Value = Rewrite( ret.Value, map ) };

			case IrExprStmt expr:
				return expr with { Value = Rewrite( expr.Value, map ) };

			case IrScope scope:
				Apply( scope.Body, map );
				return scope;

			default:
				return statement;
		}
	}

	static IEnumerable<IrBlock> Children( IrStmt statement )
	{
		switch ( statement )
		{
			case IrIf branch:
				if ( branch.Then is not null ) yield return branch.Then;
				if ( branch.Else is not null ) yield return branch.Else;
				break;

			case IrFor loop when loop.Body is not null:
				yield return loop.Body;
				break;

			case IrWhile loop when loop.Body is not null:
				yield return loop.Body;
				break;

			case IrScope scope when scope.Body is not null:
				yield return scope.Body;
				break;
		}
	}

	static string Rename( string name, Dictionary<string, string> map ) =>
		name is not null && map.TryGetValue( name, out var renamed ) ? renamed : name;

	static IrExpr Rewrite( IrExpr expr, Dictionary<string, string> map )
	{
		switch ( expr )
		{
			case null:
				return null;

			case IrVar variable:
			{
				var renamed = Rename( variable.Name, map );

				return ReferenceEquals( renamed, variable.Name ) ? variable : variable with { Name = renamed };
			}

			case IrCall call:
				return call with { Args = RewriteAll( call.Args, map ) };

			case IrHelperCall call:
				return call with { Args = RewriteAll( call.Args, map ) };

			case IrBinary binary:
				return binary with { L = Rewrite( binary.L, map ), R = Rewrite( binary.R, map ) };

			case IrUnary unary:
				return unary with { V = Rewrite( unary.V, map ) };

			case IrSwizzle swizzle:
				return swizzle with { V = Rewrite( swizzle.V, map ) };

			case IrConstruct construct:
				return construct with { Parts = RewriteAll( construct.Parts, map ) };

			case IrCast cast:
				return cast with { V = Rewrite( cast.V, map ) };

			case IrSelect select:
				return select with
				{
					C = Rewrite( select.C, map ),
					A = Rewrite( select.A, map ),
					B = Rewrite( select.B, map )
				};

			case IrIndex index:
				return index with { V = Rewrite( index.V, map ), I = Rewrite( index.I, map ) };

			case IrMember member:
				return member with { V = Rewrite( member.V, map ) };

			default:
				return expr;
		}
	}

	static IrExpr[] RewriteAll( IrExpr[] items, Dictionary<string, string> map )
	{
		if ( items is null || items.Length == 0 ) return items;

		var result = new IrExpr[items.Length];

		for ( int i = 0; i < items.Length; i++ ) result[i] = Rewrite( items[i], map );

		return result;
	}
}

// ---- boundary nodes -------------------------------------------------------------------------------

/// <summary>
/// One typed input of a subgraph.
/// <para>
/// When the document is instanced, this node produces the value the caller wired into the matching
/// port. When the document is opened on its own it produces its preview input, or its default, so a
/// subgraph can be authored and previewed in isolation.
/// </para>
/// </summary>
[NodeInfo( Id = SubgraphInputNode.TypeId, Title = "Subgraph Input", Category = "Subgraph",
	Icon = "input", Keywords = new[] { "subgraph", "input", "argument", "parameter", "function" },
	Description = "Declares one input of this subgraph. The name and type become a port on every " +
		"instance of it." )]
[NodeVersion( 1 )]
public sealed class SubgraphInputNode : PrismNode
{
	/// <summary>The stable type id.</summary>
	public const string TypeId = "prism.subgraph.input";

	string _inputType = "float";

	/// <summary>The input's name. This is what the instance's port is labelled and keyed by.</summary>
	public string InputName { get; set; } = "Value";

	/// <summary>What the input is for. Becomes the port's tooltip on every instance.</summary>
	public string InputDescription { get; set; }

	/// <summary>The input's type, spelled the way HLSL spells it.</summary>
	public string InputType
	{
		get => _inputType;
		set
		{
			var text = string.IsNullOrWhiteSpace( value ) ? "float" : value.Trim();

			if ( string.Equals( _inputType, text, StringComparison.Ordinal ) ) return;

			_inputType = text;
			RebuildPorts();
		}
	}

	/// <summary>Where the port sits on the instance's card.</summary>
	public int PortOrder { get; set; }

	/// <summary>True when an instance must wire something into this input.</summary>
	public bool IsRequired { get; set; }

	/// <summary>The value an instance uses when nothing is wired in, packed into four components.</summary>
	public Vector4 DefaultValue { get; set; }

	/// <summary>A value used only while this subgraph is being previewed on its own.</summary>
	[In( "any", Name = "Preview" )] public PortRef Preview { get; set; }

	/// <summary>The input's value.</summary>
	[Out( "any", Name = "Value" )] public PortRef Result { get; set; }

	/// <summary>The input's resolved type, falling back to <c>float</c>.</summary>
	public ShaderType ResolvedType =>
		ShaderType.TryParse( _inputType, out var type ) && !type.IsVoid ? type : ShaderType.Float;

	/// <inheritdoc/>
	protected override void OnDefinePorts( PortBuilder b )
	{
		if ( b is null ) return;

		b.Retype( nameof( Result ), ResolvedType.ToString() );
		b.SetFlags( nameof( Preview ), PortFlags.NoInlineEditor );
	}

	/// <summary>Describe this input as a boundary slot, for an instance's port table.</summary>
	public SubgraphPortInfo Describe() => new()
	{
		Name = InputName,
		Type = ResolvedType.ToString(),
		Description = InputDescription,
		Order = PortOrder,
		Required = IsRequired,
		Default = new[] { DefaultValue.x, DefaultValue.y, DefaultValue.z, DefaultValue.w }
	};

	/// <inheritdoc/>
	public override void OnValidate( ValidationContext ctx )
	{
		if ( ctx is null ) return;

		if ( string.IsNullOrWhiteSpace( InputName ) )
		{
			ctx.Error( "A subgraph input needs a name", null, DiagnosticCode.UnresolvedParameter );
			return;
		}

		if ( ctx.Graph is not null && !ctx.Graph.IsSubgraph )
		{
			ctx.Warn( "Subgraph inputs only mean something inside a .prismfn; in a shader graph this " +
				"reads as its default", null, DiagnosticCode.SubgraphUnavailable );
		}
	}

	/// <inheritdoc/>
	public override void Emit( EmitContext ctx )
	{
		if ( ctx is null ) return;

		var key = SubgraphLibrary.Identifier( InputName );

		if ( SubgraphBindings.TryGet( key, out var bound ) )
		{
			ctx.Out( nameof( Result ), bound );
			return;
		}

		if ( ctx.TryIn( nameof( Preview ), out var preview ) )
		{
			ctx.Out( nameof( Result ), preview );
			return;
		}

		var type = ResolvedType;
		var value = new ConstValue( DefaultValue.x, DefaultValue.y, DefaultValue.z, DefaultValue.w );

		ctx.Out( nameof( Result ), ctx.Const( type, value ) );
	}
}

/// <summary>
/// The terminal of a subgraph: the set of values it hands back.
/// <para>
/// Its slots are authored rather than fixed, so a subgraph can return anything from a single float to
/// a full material's worth of channels.
/// </para>
/// </summary>
[NodeInfo( Id = SubgraphOutputNode.TypeId, Title = "Subgraph Output", Category = "Subgraph",
	Icon = "output", Keywords = new[] { "subgraph", "output", "result", "return", "function" },
	Description = "Declares what this subgraph returns. Each slot becomes an output port on every " +
		"instance of it." )]
[NodeVersion( 1 )]
public sealed class SubgraphOutputNode : PrismNode, IPrismOutputNode
{
	/// <summary>The stable type id.</summary>
	public const string TypeId = "prism.subgraph.output";

	/// <summary>The values this subgraph returns, in authored order.</summary>
	public List<SubgraphPortInfo> Slots { get; set; }

	/// <inheritdoc/>
	public ShaderDomain Domain => ShaderDomain.Subgraph;

	/// <summary>Add a slot and rebuild the port set. Safe to call at any time.</summary>
	public SubgraphPortInfo AddSlot( string name = null, string type = "float" )
	{
		Slots ??= new List<SubgraphPortInfo>();

		var slot = new SubgraphPortInfo
		{
			Name = UniqueName( name ?? "Out" ),
			Type = type,
			Order = Slots.Count
		};

		Slots.Add( slot );
		RebuildPorts();

		return slot;
	}

	/// <summary>Remove a slot by name and rebuild the port set.</summary>
	public bool RemoveSlot( string name )
	{
		if ( Slots is null || string.IsNullOrWhiteSpace( name ) ) return false;

		var id = SubgraphLibrary.Identifier( name );
		var removed = Slots.RemoveAll( x => x is not null && SubgraphLibrary.Identifier( x.Name ) == id );

		if ( removed == 0 ) return false;

		RebuildPorts();
		return true;
	}

	/// <summary>Rebuild the port set after the slot table was edited in place.</summary>
	public void SlotsChanged() => RebuildPorts();

	string UniqueName( string name )
	{
		var baseName = string.IsNullOrWhiteSpace( name ) ? "Out" : name.Trim();
		var candidate = baseName;
		var index = 2;

		while ( Slots.Any( x => x is not null &&
			string.Equals( x.Name, candidate, StringComparison.OrdinalIgnoreCase ) ) )
		{
			candidate = $"{baseName}{index++}";
		}

		return candidate;
	}

	/// <inheritdoc/>
	protected override void OnDefinePorts( PortBuilder b )
	{
		if ( b is null ) return;

		var slots = Slots;

		if ( slots is null || slots.Count == 0 )
		{
			b.Input( "Out", "any", string.Empty );
			return;
		}

		var order = 0;

		foreach ( var slot in slots.OrderBy( x => x?.Order ?? 0 ) )
		{
			if ( slot is null || string.IsNullOrWhiteSpace( slot.Name ) ) continue;
			if ( b.Has( slot.PortId ) ) continue;

			b.Input( slot.PortId, slot.ResolvedType.ToString(), slot.Name,
				tooltip: slot.Description, order: order++,
				flags: slot.Required ? PortFlags.Required : PortFlags.None );
		}
	}

	/// <inheritdoc/>
	public IEnumerable<StageRoot> Roots()
	{
		foreach ( var input in Inputs )
		{
			if ( input is null ) continue;
			if ( Graph is null || !Graph.TryGetIncomingEdge( Id, input.Id, out _ ) ) continue;

			yield return new StageRoot( ShaderStage.Pixel, Id, input.Id, input.Id.Value );
		}
	}

	/// <inheritdoc/>
	public override void OnValidate( ValidationContext ctx )
	{
		if ( ctx?.Graph is null ) return;

		if ( !ctx.Graph.IsSubgraph )
		{
			ctx.Warn( "A subgraph output only means something inside a .prismfn",
				null, DiagnosticCode.SubgraphUnavailable );
		}

		if ( Slots is not null && Slots.Count > 0 ) return;

		ctx.Warn( "This subgraph returns nothing; add at least one output slot",
			null, DiagnosticCode.NoOutput );
	}

	/// <inheritdoc/>
	public override void Emit( EmitContext ctx )
	{
		// Pulled, not pushed: the instance demands each slot's input directly.
	}
}

/// <summary>
/// An instance of a <c>.prismfn</c>.
/// <para>
/// The referenced document is inlined into whatever is compiling this graph. If the file has gone
/// missing the node keeps the ports it last knew about, so every wire attached to it survives and the
/// error is a diagnostic rather than a silent loss of work.
/// </para>
/// </summary>
[NodeInfo( Id = SubgraphInstanceNode.TypeId, Title = "Subgraph", Category = "Subgraph",
	Icon = "account_tree", Keywords = new[] { "subgraph", "function", "instance", "prismfn", "reuse" },
	Description = "Runs another Prism document inline. Its inputs and outputs become this node's " +
		"ports." )]
[NodeVersion( 1 )]
public sealed class SubgraphInstanceNode : PrismNode
{
	/// <summary>The stable type id.</summary>
	public const string TypeId = "prism.subgraph.instance";

	string _subgraphPath;

	/// <summary>
	/// The content-relative path of the <c>.prismfn</c> this node instances.
	/// <para>
	/// The <c>Subgraph</c> alias is load-bearing: the graph view sets a dropped asset's path by trying a
	/// short list of conventional property names, and <c>Subgraph</c> is the one on that list.
	/// </para>
	/// </summary>
	[FormerlyKnownAs( "Subgraph" )]
	public string SubgraphPath
	{
		get => _subgraphPath;
		set
		{
			var text = string.IsNullOrWhiteSpace( value ) ? null : value.Replace( '\\', '/' ).Trim();

			if ( string.Equals( _subgraphPath, text, StringComparison.OrdinalIgnoreCase ) ) return;

			_subgraphPath = text;

			PrismLog.Guard( "Load subgraph boundary", Reload );
			RebuildPorts();
		}
	}

	/// <summary>The title the referenced document carried the last time it was read.</summary>
	public string CachedTitle { get; set; }

	/// <summary>The input slots the referenced document exposed the last time it was read.</summary>
	public List<SubgraphPortInfo> CachedInputs { get; set; }

	/// <summary>The output slots the referenced document exposed the last time it was read.</summary>
	public List<SubgraphPortInfo> CachedOutputs { get; set; }

	/// <summary>Why the referenced document could not be used, or null when it is fine.</summary>
	public string LoadError { get; private set; }

	/// <summary>True when the referenced document could not be read.</summary>
	public bool IsBroken => !string.IsNullOrEmpty( LoadError );

	/// <summary>
	/// Re-read the referenced document and rebuild the port set from it. Ports whose ids survive keep
	/// their connections; ports that disappear leave their edges as visible ghosts.
	/// </summary>
	public void Refresh()
	{
		SubgraphLibrary.Invalidate( _subgraphPath );
		Reload();
		RebuildPorts();
	}

	/// <summary>Re-read the referenced document without rebuilding ports.</summary>
	void Reload()
	{
		if ( string.IsNullOrWhiteSpace( _subgraphPath ) )
		{
			LoadError = "No subgraph is selected";
			return;
		}

		var graph = SubgraphLibrary.Load( _subgraphPath, out var error );

		LoadError = error;

		if ( graph is null ) return;

		var inputs = SubgraphLibrary.DescribeInputs( graph );
		var outputs = SubgraphLibrary.DescribeOutputs( graph );

		// A document that declares nothing is more likely mid-edit than genuinely empty, so the last
		// known boundary is kept rather than throwing every wire away.
		if ( inputs.Count > 0 || outputs.Count > 0 || CachedInputs is null )
		{
			CachedInputs = inputs;
			CachedOutputs = outputs;
		}

		if ( !string.IsNullOrWhiteSpace( graph.Meta?.Title ) ) CachedTitle = graph.Meta.Title;
	}

	/// <inheritdoc/>
	protected override void OnDefinePorts( PortBuilder b )
	{
		if ( b is null ) return;

		var inputs = CachedInputs;
		var outputs = CachedOutputs;

		if ( inputs is null && outputs is null && !string.IsNullOrWhiteSpace( _subgraphPath ) )
		{
			PrismLog.Guard( "Load subgraph boundary", Reload );

			inputs = CachedInputs;
			outputs = CachedOutputs;
		}

		var order = 0;

		foreach ( var slot in ( inputs ?? new List<SubgraphPortInfo>() ).OrderBy( x => x?.Order ?? 0 ) )
		{
			if ( slot is null || string.IsNullOrWhiteSpace( slot.Name ) ) continue;
			if ( b.Has( slot.PortId ) ) continue;

			b.Input( slot.PortId, slot.ResolvedType.ToString(), slot.Name,
				tooltip: slot.Description, order: order++,
				flags: slot.Required ? PortFlags.Required : PortFlags.None );
		}

		order = 0;

		foreach ( var slot in ( outputs ?? new List<SubgraphPortInfo>() ).OrderBy( x => x?.Order ?? 0 ) )
		{
			if ( slot is null || string.IsNullOrWhiteSpace( slot.Name ) ) continue;
			if ( b.Has( slot.PortId ) ) continue;

			b.Output( slot.PortId, slot.ResolvedType.ToString(), slot.Name,
				tooltip: slot.Description, order: order++ );
		}
	}

	/// <inheritdoc/>
	public override void OnValidate( ValidationContext ctx )
	{
		if ( ctx is null ) return;

		if ( string.IsNullOrWhiteSpace( _subgraphPath ) )
		{
			ctx.Error( "This node is not pointing at a subgraph", null, DiagnosticCode.SubgraphUnavailable );
			return;
		}

		if ( !IsBroken ) return;

		ctx.Error( LoadError, null, DiagnosticCode.SubgraphUnavailable );
	}

	/// <inheritdoc/>
	public override void Emit( EmitContext ctx )
	{
		if ( ctx is null ) return;

		PrismLog.Guard( $"Inline subgraph '{_subgraphPath}'", () => Inline( ctx ) );
	}

	void Inline( EmitContext ctx )
	{
		if ( ctx is not NodeEmitContext host )
		{
			ctx.Error( "A subgraph can only be inlined by the Prism compiler" );
			Fail( ctx );
			return;
		}

		if ( SubgraphBindings.IsActive( _subgraphPath ) )
		{
			ctx.Error( $"\"{_subgraphPath}\" instances itself, directly or through another subgraph" );
			Fail( ctx );
			return;
		}

		if ( SubgraphBindings.Depth >= MaxDepth )
		{
			ctx.Error( $"Subgraphs are nested more than {MaxDepth} deep" );
			Fail( ctx );
			return;
		}

		var document = SubgraphLibrary.Load( _subgraphPath, out var error );

		if ( document is null )
		{
			ctx.Error( error ?? $"Cannot find a subgraph at \"{_subgraphPath}\"" );
			Fail( ctx );
			return;
		}

		var terminal = SubgraphLibrary.Terminal( document );

		if ( terminal is null )
		{
			ctx.Error( $"\"{_subgraphPath}\" has no Subgraph Output node, so it returns nothing" );
			Fail( ctx );
			return;
		}

		var prefix = "sg" + Id.Value + "_";

		// Read every argument first: an outer subgraph's inputs must still resolve against the outer
		// binding frame while this node is collecting them.
		//
		// Each one is then bound through a local named after *this instance*. That is what keeps the
		// splice sound: everything the inlined body can reach is either one of these — whose names no
		// temp allocator ever mints — or a local it declared itself, which gets renamed below. Passing
		// the caller's own temps straight in would leave names that the rename pass cannot tell apart
		// from the subgraph's.
		var arguments = new Dictionary<string, IrValue>( StringComparer.OrdinalIgnoreCase );

		foreach ( var input in Inputs )
		{
			if ( input is null ) continue;
			if ( !ctx.TryIn( input.Id.Value, out var value ) ) continue;
			if ( !value.IsValid || value.Type.IsVoid ) continue;

			arguments[input.Id.Value] =
				host.Builder.Declare( Id, prefix + "in_" + input.Id.Value, value.Type, value );
		}

		var stage = ctx.Stage;

		var roots = terminal.Inputs
			.Where( x => x is not null && document.TryGetIncomingEdge( terminal.Id, x.Id, out _ ) )
			.Select( x => new StageRoot( stage, terminal.Id, x.Id, x.Id.Value ) )
			.ToArray();

		var request = new CompileRequest( document, ctx.Mode )
		{
			Dialect = host.Emitter?.Request?.Dialect ?? Compiler.Backends.HlslDialect.SboxSlang,
			DebugSymbols = host.Emitter?.DebugSymbols ?? false,
			EmitComments = false,
			OutputName = SubgraphLibrary.Identifier( CachedTitle ?? "subgraph" )
		};

		var plan = new StagePlanner( document, roots, ctx.Diagnostics )
		{
			AllowVaryingPromotion = stage == ShaderStage.Pixel
		}.Plan();

		// The inner emitter shares the outer interpolator budget — a subgraph's varyings are the host
		// shader's varyings — but the inlined document's node ids are identical for every instance of
		// the same .prismfn. KeyPrefix is what stops two instances from being handed the same register
		// and clobbering each other's vertex-side write. It is the same prefix the local rename uses.
		var inner = new NodeEmitter( document, host.Module, request, ctx.Diagnostics,
			ctx.Backend, plan, host.Emitter?.Varyings )
		{
			// Composed with the host's own prefix, so nesting scopes the same way the local rename does:
			// two instances of an outer subgraph that each contain the same inner one still get four
			// distinct keys rather than two.
			KeyPrefix = ( host.Emitter?.KeyPrefix ?? string.Empty ) + prefix
		};

		var results = new Dictionary<PortId, IrValue>();

		using ( SubgraphBindings.Push( _subgraphPath, arguments ) )
		{
			foreach ( var input in terminal.Inputs )
			{
				if ( input is null ) continue;

				var context = new NodeEmitContext( inner, terminal, stage, inner.Builder( stage ) );

				results[input.Id] = inner.DemandInput( terminal, input, stage, context );
			}
		}

		var map = Splice( host, inner, stage, prefix );

		foreach ( var output in Outputs )
		{
			if ( output is null ) continue;

			ctx.Out( output.Id.Value, results.TryGetValue( output.Id, out var value )
				? IrRewriter.Rewrite( value, map )
				: IrValue.Invalid );
		}
	}

	/// <summary>
	/// Move everything the inlined document emitted into the caller's stage builders, renaming its
	/// locals first so nothing can shadow a temp the caller is still holding. Returns the rename map,
	/// which the caller has to run its result values through as well.
	/// </summary>
	static Dictionary<string, string> Splice( NodeEmitContext host, NodeEmitter inner, ShaderStage stage,
		string prefix )
	{
		var blocks = new List<(ShaderStage Stage, IrBlock Block)>();

		foreach ( var emitted in ShaderStages.All )
		{
			if ( emitted == ShaderStage.None ) continue;

			var source = inner.Builder( emitted );

			if ( source.Root.IsEmpty ) continue;

			blocks.Add( (emitted, source.Root) );
		}

		var map = IrRewriter.BuildMap( blocks.Select( x => x.Block ), prefix );

		foreach ( var (emitted, block) in blocks )
		{
			IrRewriter.Apply( block, map );

			// The stage being emitted lands wherever the caller is currently writing; another stage —
			// the vertex half of an interpolated value, say — lands at the end of that stage's body.
			if ( emitted == stage )
			{
				foreach ( var statement in block.Statements ) host.Builder.Add( statement );
				continue;
			}

			var target = host.Emitter?.Builder( emitted );

			if ( target is null ) continue;

			foreach ( var statement in block.Statements ) target.Root.Add( statement );
		}

		return map;
	}

	void Fail( EmitContext ctx )
	{
		foreach ( var output in Outputs )
		{
			if ( output is null ) continue;

			ctx.Out( output.Id.Value, IrValue.Invalid );
		}
	}

	/// <summary>How deep subgraph instancing is allowed to go before it is treated as a mistake.</summary>
	public const int MaxDepth = 16;
}