Editor/Prism/Serialization/LegacyShaderGraphImporter.cs

Editor-side importer for Unity/ShaderGraph legacy .shdrgrph and .shdrfunc documents, it parses legacy JSON, remaps legacy node classes and ports to Prism node types, recreates stable ids, rebuilds edges, promotes parameters, and preserves unmapped nodes as placeholders.

File AccessReflectionNetworking
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Core;
using Editor.Prism.Model;
using System.IO;
using System.Text;

namespace Editor.Prism.Serialization;

/// <summary>
/// How one built-in ShaderGraph node class maps onto a Prism node type.
/// <para>
/// The table is data, not code, and every entry is replaceable through
/// <see cref="LegacyShaderGraphImporter.Register"/>. An unmapped class is <em>not</em> a failure: the
/// node is imported as an <see cref="UnknownNode"/> carrying its original JSON, so nothing is lost
/// and the user gets a placeholder they can replace by hand.
/// </para>
/// </summary>
public sealed record LegacyNodeMapping( string LegacyClass, string PrismTypeId )
{
	/// <summary>Legacy plug name to Prism port id. Anything absent keeps its name.</summary>
	public IReadOnlyDictionary<string, string> Ports { get; init; }

	/// <summary>Legacy property name to Prism property name. Anything absent keeps its name.</summary>
	public IReadOnlyDictionary<string, string> Properties { get; init; }

	/// <summary>
	/// Optional final pass over the produced Prism node object, for anything the two dictionaries
	/// cannot express. Runs after ports and properties have been renamed.
	/// </summary>
	public Func<JsonObject, JsonObject> Transform { get; init; }

	/// <summary>Note attached as an informational diagnostic when this mapping is used.</summary>
	public string Note { get; init; }

	/// <summary>Translate a legacy plug name.</summary>
	public string MapPort( string name ) =>
		Ports is not null && Ports.TryGetValue( name, out var mapped ) ? mapped : name;

	/// <summary>Translate a legacy property name. Returns null when the property should be dropped.</summary>
	public string MapProperty( string name ) =>
		Properties is not null && Properties.TryGetValue( name, out var mapped ) ? mapped : name;

	/// <inheritdoc/>
	public override string ToString() => $"{LegacyClass} -> {PrismTypeId}";
}

/// <summary>Knobs for a legacy import.</summary>
public sealed record LegacyImportOptions
{
	/// <summary>
	/// Set <c>fill = 0</c> on every imported connection so padding conversions reproduce the old
	/// compiler's behaviour exactly.
	/// <para>
	/// The built-in compiler pads a narrow value into a wider one with <b>zero</b> and never splats.
	/// Prism's default fill is smarter (a <c>float3 -&gt; float4</c> pad fills the alpha with 1), which
	/// is what you want for new work and exactly what you do not want for a graph that already looks
	/// right. Pinning the fill per edge makes an imported graph render identically on day one; the
	/// user can clear it per connection afterwards.
	/// </para>
	/// </summary>
	public bool PreserveLegacyPadFill { get; init; } = true;

	/// <summary>Turn named parameter nodes into blackboard parameters plus a reference node.</summary>
	public bool PromoteParameters { get; init; } = true;

	/// <summary>Grid step positions are snapped to on import. Zero disables snapping.</summary>
	public float SnapGrid { get; init; } = PrismConstants.GridSize;

	/// <summary>The default options.</summary>
	public static readonly LegacyImportOptions Default = new();
}

/// <summary>
/// Imports the built-in editor's <c>.shdrgrph</c> / <c>.shdrfunc</c> documents.
/// <para>
/// The legacy format has no edge list — a connection is a <c>{ Identifier, Output }</c> object stored
/// as a property on the <em>consuming</em> node — and it renumbers every identifier to a dense
/// <c>"0","1","2"</c> sequence on each save. Both are undone here: connections become a real edge
/// list with stable ids, and every node gets a freshly minted base36 id that will never be rewritten
/// again.
/// </para>
/// </summary>
public static class LegacyShaderGraphImporter
{
	static readonly object s_lock = new();
	static Dictionary<string, LegacyNodeMapping> s_mappings;

	/// <summary>The legacy file extensions this importer accepts.</summary>
	public static readonly IReadOnlyList<string> Extensions = new[] { "shdrgrph", "shdrfunc" };

	/// <summary>Every registered mapping, keyed by legacy class name.</summary>
	public static IReadOnlyDictionary<string, LegacyNodeMapping> Mappings
	{
		get
		{
			EnsureBuilt();

			lock ( s_lock )
			{
				return new Dictionary<string, LegacyNodeMapping>( s_mappings, StringComparer.OrdinalIgnoreCase );
			}
		}
	}

	/// <summary>Add or replace a mapping.</summary>
	public static void Register( LegacyNodeMapping mapping )
	{
		if ( mapping is null || string.IsNullOrWhiteSpace( mapping.LegacyClass ) ) return;

		EnsureBuilt();

		lock ( s_lock )
		{
			s_mappings[mapping.LegacyClass] = mapping;
		}
	}

	/// <summary>Reset the table to the shipped defaults.</summary>
	public static void Reset()
	{
		lock ( s_lock )
		{
			s_mappings = null;
			s_inlineBindings.Clear();
		}
	}

	/// <summary>True when this path looks like a built-in shader graph document.</summary>
	public static bool CanImport( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) ) return false;

		var extension = Path.GetExtension( path ).TrimStart( '.' );

		return Extensions.Contains( extension, StringComparer.OrdinalIgnoreCase );
	}

	/// <summary>Import a legacy document from disk.</summary>
	public static PrismGraph ImportFile( string absolutePath, DiagnosticSink sink = null,
		LegacyImportOptions options = null )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) ) return null;

		string text;

		try
		{
			text = File.ReadAllText( absolutePath, Encoding.UTF8 );
		}
		catch ( Exception e )
		{
			sink?.Error( DiagnosticCode.SectionReadFailed, $"Could not read '{absolutePath}': {e.Message}" );
			return null;
		}

		var subgraph = string.Equals( Path.GetExtension( absolutePath ).TrimStart( '.' ), "shdrfunc",
			StringComparison.OrdinalIgnoreCase );

		var graph = Import( text, subgraph, sink, options );

		if ( graph is not null )
		{
			graph.Meta.Title ??= Path.GetFileNameWithoutExtension( absolutePath );
		}

		return graph;
	}

	/// <summary>
	/// Import a legacy document. Returns null only when the text is not JSON; every other problem
	/// degrades to a diagnostic and a partially converted graph.
	/// </summary>
	public static PrismGraph Import( string json, bool isSubgraph, DiagnosticSink sink = null,
		LegacyImportOptions options = null )
	{
		options ??= LegacyImportOptions.Default;

		if ( string.IsNullOrWhiteSpace( json ) )
		{
			sink?.Error( DiagnosticCode.SectionReadFailed, "The legacy document is empty" );
			return null;
		}

		JsonObject root;

		try
		{
			root = JsonNode.Parse( json, null, new JsonDocumentOptions
			{
				AllowTrailingCommas = true,
				CommentHandling = JsonCommentHandling.Skip
			} ) as JsonObject;
		}
		catch ( Exception e )
		{
			sink?.Error( DiagnosticCode.SectionReadFailed, $"The legacy document is not valid JSON: {e.Message}" );
			return null;
		}

		if ( root is null )
		{
			sink?.Error( DiagnosticCode.SectionReadFailed, "The legacy document's root is not a JSON object" );
			return null;
		}

		EnsureBuilt();

		var subgraph = isSubgraph || ReadBool( root["IsSubgraph"] );
		var document = new JsonObject
		{
			["schema"] = PrismConstants.DocumentSchemaVersion,
			["id"] = Ids.NewShortId(),
			["kind"] = subgraph ? PrismConstants.DocumentKindSubgraph : PrismConstants.DocumentKindShader
		};

		var meta = new JsonObject { ["editorVersion"] = PrismConstants.EditorVersion };
		var description = ValueCodec.StringOf( root["Description"] );

		if ( !string.IsNullOrEmpty( description ) ) meta["description"] = description;

		document["meta"] = meta;
		document["settings"] = ImportSettings( root, subgraph );

		var model = ValueCodec.StringOf( root["Model"] );

		if ( !string.IsNullOrEmpty( model ) )
		{
			document["preview"] = new JsonObject { ["mesh"] = "Sphere", ["model"] = model };
		}

		var nodes = new JsonArray();
		var edges = new JsonArray();
		var parameters = new JsonArray();
		var ids = new Dictionary<string, string>( StringComparer.Ordinal );
		var mappingByNewId = new Dictionary<string, LegacyNodeMapping>( StringComparer.Ordinal );

		var source = root["nodes"] as JsonArray ?? root["Nodes"] as JsonArray;

		if ( source is null )
		{
			sink?.Warn( DiagnosticCode.SectionReadFailed, "The legacy document has no 'nodes' array" );
			source = new JsonArray();
		}

		// Pass one: mint ids so connections can be resolved regardless of declaration order.
		var entries = new List<(JsonObject Source, string NewId, string LegacyClass)>();

		foreach ( var element in source )
		{
			if ( element is not JsonObject node )
			{
				sink?.Warn( DiagnosticCode.NodeReadFailed, "A legacy node entry was not an object and was skipped" );
				continue;
			}

			var legacyId = ValueCodec.StringOf( node["Identifier"] ) ?? Ids.NewShortId();
			var legacyClass = ValueCodec.StringOf( node["_class"] ) ?? "Unknown";
			var newId = Ids.NewShortId();

			ids[legacyId] = newId;
			entries.Add( (node, newId, legacyClass) );
		}

		// Pass two: convert each node and collect its connections.
		foreach ( var (node, newId, legacyClass) in entries )
		{
			LegacyNodeMapping mapping;

			lock ( s_lock )
			{
				s_mappings.TryGetValue( legacyClass, out mapping );
			}

			mappingByNewId[newId] = mapping;

			var converted = ConvertNode( node, newId, legacyClass, mapping, parameters, options, sink );

			if ( converted is not null ) nodes.Add( converted );
		}

		// Pass three: rebuild the connections that used to live on the consuming node.
		foreach ( var (node, newId, _) in entries )
		{
			var mapping = mappingByNewId.TryGetValue( newId, out var found ) ? found : null;

			foreach ( var pair in node )
			{
				if ( pair.Value is not JsonObject link ) continue;
				if ( !link.ContainsKey( "Identifier" ) || !link.ContainsKey( "Output" ) ) continue;

				var sourceId = ValueCodec.StringOf( link["Identifier"] );
				var sourcePort = ValueCodec.StringOf( link["Output"] );

				if ( string.IsNullOrEmpty( sourceId ) || string.IsNullOrEmpty( sourcePort ) ) continue;
				if ( !ids.TryGetValue( sourceId, out var fromNode ) ) continue;

				var fromMapping = mappingByNewId.TryGetValue( fromNode, out var fm ) ? fm : null;

				var edge = new JsonObject
				{
					["id"] = Ids.NewShortId(),
					["from"] = new JsonObject
					{
						["node"] = fromNode,
						["port"] = fromMapping?.MapPort( sourcePort ) ?? sourcePort
					},
					["to"] = new JsonObject
					{
						["node"] = newId,
						["port"] = mapping?.MapPort( pair.Key ) ?? pair.Key
					}
				};

				if ( options.PreserveLegacyPadFill ) edge["fill"] = 0;

				edges.Add( edge );
			}
		}

		if ( parameters.Count > 0 ) document["parameters"] = parameters;

		document["nodes"] = nodes;

		if ( edges.Count > 0 ) document["edges"] = edges;

		var graph = PrismSerializer.ReadJson( document, sink );

		if ( graph is null ) return null;

		graph.IsDirty = true;

		sink?.Info( DiagnosticCode.Migrated,
			$"Imported {nodes.Count} nodes and {edges.Count} connections from a built-in shader graph",
			null, options.PreserveLegacyPadFill
				? "Every connection was pinned to a zero pad fill so padding conversions match the old " +
					"compiler exactly. Clear a connection's fill to use Prism's default."
				: null );

		return graph;
	}

	static JsonObject ImportSettings( JsonObject root, bool subgraph )
	{
		var blend = ValueCodec.StringOf( root["BlendMode"] ) ?? "Opaque";
		var shading = ValueCodec.StringOf( root["ShadingModel"] ) ?? "Lit";
		var domain = ValueCodec.StringOf( root["Domain"] ) ?? ( subgraph ? "Subgraph" : "Surface" );

		return new JsonObject
		{
			["domain"] = domain,
			["shadingModel"] = shading,
			["blendMode"] = blend,
			["cullMode"] = "Back",
			["modes"] = new JsonArray( "Forward", "Depth", "ToolsShadingComplexity" ),
			["targets"] = new JsonArray( PrismConstants.BackendHlsl ),
			["hlslDialect"] = HlslDialectName,
			["uv2"] = false,
			["renderBackfaces"] = false
		};
	}

	static JsonObject ConvertNode( JsonObject node, string newId, string legacyClass,
		LegacyNodeMapping mapping, JsonArray parameters, LegacyImportOptions options, DiagnosticSink sink )
	{
		var position = ReadPosition( ValueCodec.StringOf( node["Position"] ), options.SnapGrid );

		if ( mapping is null )
		{
			// No mapping: keep the original object verbatim under a type id that will not resolve, so
			// the node survives as a placeholder holding every byte it arrived with.
			var preserved = node.DeepClone() as JsonObject;

			preserved["id"] = newId;
			preserved["type"] = LegacyTypePrefix + legacyClass;
			preserved["v"] = 1;
			preserved["pos"] = ValueCodec.Vector( position.x, position.y );

			sink?.Warn( DiagnosticCode.UnknownNodeType,
				$"No Prism equivalent is registered for the built-in '{legacyClass}' node",
				null, "It was imported as a placeholder holding its original data. Replace it by hand." );

			return preserved;
		}

		if ( options.PromoteParameters && s_parameterClasses.Contains( legacyClass ) &&
			!string.IsNullOrWhiteSpace( ValueCodec.StringOf( node["Name"] ) ) )
		{
			return ConvertParameterNode( node, newId, legacyClass, position, parameters );
		}

		var props = new JsonObject();

		foreach ( var pair in node )
		{
			if ( s_reservedKeys.Contains( pair.Key ) ) continue;
			if ( pair.Value is JsonObject link && link.ContainsKey( "Identifier" ) && link.ContainsKey( "Output" ) )
			{
				continue;
			}

			var name = mapping.MapProperty( pair.Key );

			if ( string.IsNullOrEmpty( name ) ) continue;

			props[name] = pair.Value?.DeepClone();
		}

		var converted = new JsonObject
		{
			["id"] = newId,
			["type"] = mapping.PrismTypeId,
			["v"] = 1,
			["pos"] = ValueCodec.Vector( position.x, position.y ),
			["props"] = props
		};

		if ( mapping.Transform is not null )
		{
			converted = PrismLog.Guard( $"Transform legacy '{legacyClass}'",
				() => mapping.Transform( converted ), converted ) ?? converted;
		}

		// After the transform, so a transform that placed an inline value itself keeps it.
		CarryInlineValues( converted, mapping.PrismTypeId );

		if ( !string.IsNullOrEmpty( mapping.Note ) )
		{
			sink?.Info( DiagnosticCode.Migrated, $"'{legacyClass}': {mapping.Note}" );
		}

		return converted;
	}

	static JsonObject ConvertParameterNode( JsonObject node, string newId, string legacyClass,
		Vector2 position, JsonArray parameters )
	{
		var paramId = Ids.NewShortId();
		var name = ValueCodec.StringOf( node["Name"] ) ?? "Parameter";

		var type = legacyClass switch
		{
			"Float2" => "float2",
			"Float3" => "float3",
			"Float4" => "float4",
			"TextureSampler" => "Texture2D",
			_ => "float"
		};

		var parameter = new JsonObject
		{
			["id"] = paramId,
			["name"] = name,
			["type"] = type
		};

		if ( string.Equals( legacyClass, "TextureSampler", StringComparison.Ordinal ) )
		{
			var image = ValueCodec.StringOf( node["Image"] );

			if ( !string.IsNullOrEmpty( image ) )
			{
				var srgb = node["UI"] is JsonObject textureUi && ReadBool( textureUi["SrgbRead"], true );

				parameter["default"] = new JsonObject
				{
					["path"] = image,
					["colorSpace"] = srgb ? "Srgb" : "Linear"
				};
			}
		}
		else if ( node["Value"] is { } value )
		{
			parameter["default"] = value.DeepClone();
		}

		var ui = new JsonObject();

		if ( node["UI"] is JsonObject legacyUi )
		{
			var group = ReadGroupName( legacyUi );

			if ( !string.IsNullOrEmpty( group ) ) ui["group"] = group;

			var uiType = ValueCodec.StringOf( legacyUi["Type"] );

			if ( string.Equals( uiType, "Slider", StringComparison.OrdinalIgnoreCase ) ) ui["control"] = "Slider";
			if ( string.Equals( uiType, "Color", StringComparison.OrdinalIgnoreCase ) ) ui["control"] = "Color";
		}

		if ( node["Min"] is { } min ) ui["min"] = min.DeepClone();
		if ( node["Max"] is { } max ) ui["max"] = max.DeepClone();

		if ( ui.Count > 0 ) parameter["ui"] = ui;

		if ( ReadBool( node["IsAttribute"] ) ) parameter["attribute"] = Parameter.Sanitize( name );

		parameters.Add( parameter );

		return new JsonObject
		{
			["id"] = newId,
			["type"] = ParameterRefTypeId,
			["v"] = 1,
			["pos"] = ValueCodec.Vector( position.x, position.y ),
			["props"] = new JsonObject { ["Parameter"] = paramId }
		};
	}

	static Vector2 ReadPosition( string text, float snap )
	{
		var position = Vector2.Zero;

		if ( !string.IsNullOrWhiteSpace( text ) )
		{
			var parts = text.Split( ',', StringSplitOptions.TrimEntries );

			if ( parts.Length >= 2 &&
				float.TryParse( parts[0], System.Globalization.NumberStyles.Float,
					System.Globalization.CultureInfo.InvariantCulture, out var x ) &&
				float.TryParse( parts[1], System.Globalization.NumberStyles.Float,
					System.Globalization.CultureInfo.InvariantCulture, out var y ) )
			{
				position = new Vector2( x, y );
			}
		}

		if ( snap <= 0f ) return position;

		return new Vector2(
			MathF.Round( position.x / snap ) * snap,
			MathF.Round( position.y / snap ) * snap );
	}

	/// <summary>
	/// Move an unnamed legacy constant's <c>Value</c> onto the field <see cref="Nodes.ConstantNode"/>
	/// keeps it in, and set the matching <c>Kind</c>.
	/// <remarks>
	/// The constant node stores its value in one of <c>ScalarValue</c> / <c>Vector2Value</c> /
	/// <c>Vector3Value</c> / <c>Vector4Value</c> / <c>ColorValue</c>, selected by <c>Kind</c>. It has no
	/// property called <c>Value</c>, so without this the legacy value is silently dropped and every
	/// unnamed constant in the graph imports as <b>zero</b> — which does not fail, does not warn, and
	/// quietly rewrites the maths. A <c>1 - mask</c> becomes <c>0 - mask</c>.
	/// <para>
	/// <c>Kind</c> also decides what the component outputs are called, so a legacy <c>Float4</c> has to
	/// land as <see cref="Nodes.PrismConstantKind.Color"/> rather than <c>Float4</c>: the built-in node
	/// is a <c>ParameterNode&lt;Color&gt;</c> whose plugs are R/G/B/A, and a Float4-kind constant would
	/// expose X/Y/Z/W instead, breaking every edge that leaves one.
	/// </para>
	/// </remarks>
	/// </summary>
	/// <summary>
	/// Pin a legacy branch's condition to the state it actually defaulted to.
	/// <remarks>
	/// The built-in Branch never takes its condition from a plug. Given a <c>Name</c> it compiles to a
	/// bool material attribute defaulting to <c>Enabled</c>; given none it compares its <c>A</c> and
	/// <c>B</c> plugs with <c>Operator</c>. Prism's branch takes a <c>Condition</c> input whose literal
	/// defaults to <b>true</b>, so an import that carries neither form silently picks the opposite side
	/// of every branch that was switched off — which is how a graph comes across looking like a different
	/// shader rather than a broken one.
	/// <para>
	/// The named form is reproduced exactly, as a literal. The switchability is not: restoring that needs
	/// a bool blackboard parameter and a reference node wired into <c>Condition</c>, which is a change to
	/// the shape of the graph rather than to a property. The comparison form is not reproduced either —
	/// it needs a Compare node inserted between <c>A</c>/<c>B</c> and the branch. Both are called out in
	/// the mapping note so the import does not pretend otherwise.
	/// </para>
	/// </remarks>
	/// </summary>
	static JsonObject CarryBranch( JsonObject converted )
	{
		if ( converted?["props"] is not JsonObject props ) return converted;

		// Only the named form has a knowable answer; the comparison form is left at Prism's own default
		// rather than guessed at from a comparison we did not import.
		if ( !string.IsNullOrWhiteSpace( ValueCodec.StringOf( props["Name"] ) ) )
			props["DefaultCondition"] = ReadBool( props["Enabled"] );

		foreach ( var key in s_legacyBranchKeys ) props.Remove( key );

		return converted;
	}

	/// <summary>
	/// Mirror every imported property that backs a port's inline literal into that port's inline slot.
	/// <remarks>
	/// An unconnected input reads <c>Port.InlineValue</c>, not the <c>[InlineValue]</c> property behind
	/// it. The two are kept in step by <c>SyncInlineFor</c> whenever the editor writes a property, but an
	/// imported document sets properties directly, so the port keeps the literal it was built with and
	/// the property is ignored by everything that matters. A Branch whose condition was authored as
	/// <c>false</c> therefore imports, reads Prism's own default of <c>true</c>, and silently renders the
	/// other side of the branch.
	/// <para>
	/// An inline value already written by a mapping's transform wins — <c>MoveOutputDefaultsToInline</c>
	/// removes the properties it consumes, so re-deriving them here would put the C# defaults back.
	/// </para>
	/// </remarks>
	/// </summary>
	static void CarryInlineValues( JsonObject converted, string prismTypeId )
	{
		if ( converted?["props"] is not JsonObject props || props.Count == 0 ) return;

		var bindings = InlineBindings( prismTypeId );

		if ( bindings.Count == 0 ) return;

		var inline = converted["inline"] as JsonObject;

		foreach ( var (property, port) in bindings )
		{
			if ( props[property] is not { } value ) continue;
			if ( inline is not null && inline.ContainsKey( port ) ) continue;

			inline ??= new JsonObject();
			inline[port] = value.DeepClone();
		}

		if ( inline is { Count: > 0 } ) converted["inline"] = inline;
	}

	/// <summary>
	/// Which properties of a Prism node type back a port's inline literal, and which port each one
	/// feeds. Built by instantiating the type once and reading its ports; cached, and dropped by
	/// <see cref="Reset"/> along with the mapping table.
	/// </summary>
	static IReadOnlyList<(string Property, string Port)> InlineBindings( string prismTypeId )
	{
		if ( string.IsNullOrEmpty( prismTypeId ) ) return Array.Empty<(string, string)>();

		lock ( s_lock )
		{
			if ( s_inlineBindings.TryGetValue( prismTypeId, out var cached ) ) return cached;
		}

		var bindings = new List<(string Property, string Port)>();

		PrismLog.Guard( $"Reading the inline port bindings of '{prismTypeId}'", () =>
		{
			var probe = NodeRegistry.Create( prismTypeId );

			if ( probe is null ) return;

			foreach ( var port in probe.Inputs )
			{
				var property = port?.Def?.InlineValueProperty;

				if ( string.IsNullOrEmpty( property ) || !port.Id.IsValid ) continue;

				// Object ports — textures, samplers, buffers — are excluded. Their inline slot holds a
				// resource reference, not a literal, so writing a texture node's DefaultTexture path into
				// it makes the loader coerce a string into a Texture2D and lose it. The emitter already
				// falls back to the bound property when an object port's slot is empty, which is exactly
				// the state we want to leave it in.
				if ( port.Def.FixedType.IsObject ) continue;

				bindings.Add( (property, port.Id.Value) );
			}
		} );

		lock ( s_lock )
		{
			s_inlineBindings[prismTypeId] = bindings;
		}

		return bindings;
	}

	/// <summary>
	/// Translate the legacy texture-coordinate node's <c>UseSecondaryCoord</c> flag into the UV channel
	/// index Prism uses. Dropping it silently would sample UV0 in a graph that asked for UV1, which shows
	/// up as a completely wrong lookup rather than as an error.
	/// <remarks>
	/// The node's <c>Tiling</c> is <em>not</em> carried: Prism's texcoord node has no tiling of its own,
	/// and reproducing it means inserting a multiply into the graph rather than setting a property. A
	/// non-identity tiling therefore still needs fixing by hand after import.
	/// </remarks>
	/// </summary>
	static JsonObject CarryTexCoord( JsonObject converted )
	{
		if ( converted?["props"] is not JsonObject props ) return converted;

		if ( props["UseSecondaryCoord"] is { } secondary )
			props["Channel"] = ReadBool( secondary ) ? 1 : 0;

		props.Remove( "UseSecondaryCoord" );

		return converted;
	}

	static Func<JsonObject, JsonObject> CarryConstant( string kind, string valueProperty ) => converted =>
	{
		if ( converted?["props"] is not JsonObject props ) return converted;

		var value = props["Value"];

		// Kind first: it drives the port rebuild, and the value field it selects is read after.
		props["Kind"] = kind;

		if ( value is not null ) props[valueProperty] = value.DeepClone();

		foreach ( var key in s_legacyParameterKeys ) props.Remove( key );

		return converted;
	};

	/// <summary>
	/// Unpack a legacy texture node's <c>Image</c>, <c>Sampler</c> and <c>UI</c> onto the slot fields
	/// <see cref="Nodes.TextureNode"/> actually declares.
	/// <remarks>
	/// Without this the three arrive as properties named <c>Image</c>, <c>Sampler</c> and <c>UI</c> —
	/// because <see cref="LegacyNodeMapping.MapProperty"/> passes an unlisted name through unchanged —
	/// and the deserializer drops all three, since no Prism node has a property by any of those names.
	/// The texture path is the visible casualty: the node imports with the stock white fallback and no
	/// trace of the asset the graph was built around. <c>Sampler</c> is the subtle one, because Prism
	/// does have a <c>Sampler</c>, but it is an input <em>port</em> rather than a property.
	/// </remarks>
	/// </summary>
	static JsonObject CarryTextureSlot( JsonObject converted )
	{
		if ( converted?["props"] is not JsonObject props ) return converted;

		var image = ValueCodec.StringOf( props["Image"] );

		if ( !string.IsNullOrWhiteSpace( image ) ) props["DefaultTexture"] = image;

		props.Remove( "Image" );

		if ( props["Sampler"] is JsonObject sampler )
		{
			var filter = ValueCodec.StringOf( sampler["Filter"] );
			var addressU = ValueCodec.StringOf( sampler["AddressU"] );
			var addressV = ValueCodec.StringOf( sampler["AddressV"] );

			if ( !string.IsNullOrWhiteSpace( filter ) ) props["Filter"] = MapSamplerFilter( filter );
			if ( !string.IsNullOrWhiteSpace( addressU ) ) props["AddressU"] = MapSamplerAddress( addressU );
			if ( !string.IsNullOrWhiteSpace( addressV ) ) props["AddressV"] = MapSamplerAddress( addressV );
		}

		props.Remove( "Sampler" );

		if ( props["UI"] is JsonObject ui )
		{
			var name = ValueCodec.StringOf( ui["Name"] );
			var group = ReadGroupName( ui );

			if ( !string.IsNullOrWhiteSpace( name ) ) props["TextureName"] = name;
			if ( !string.IsNullOrWhiteSpace( group ) ) props["TextureGroup"] = group;

			if ( ui["SrgbRead"] is { } srgb ) props["Srgb"] = ReadBool( srgb, true );
		}

		props.Remove( "UI" );

		return converted;
	}

	/// <summary>
	/// The material-editor heading a legacy node declares. Current documents nest it under
	/// <c>UI.PrimaryGroup.Name</c>; older ones wrote a bare <c>UIGroup</c> or <c>Group</c> string.
	/// </summary>
	static string ReadGroupName( JsonObject ui )
	{
		if ( ui is null ) return null;

		if ( ui["PrimaryGroup"] is JsonObject primary )
		{
			var nested = ValueCodec.StringOf( primary["Name"] );

			if ( !string.IsNullOrWhiteSpace( nested ) ) return nested;
		}

		return ValueCodec.StringOf( ui["UIGroup"] ) ?? ValueCodec.StringOf( ui["Group"] );
	}

	/// <summary>
	/// <c>Editor.ShaderGraph.SamplerFilter</c> to <see cref="Nodes.PrismTextureFilter"/>. Only the
	/// anisotropic member is spelled differently; the rest match by name.
	/// </summary>
	static string MapSamplerFilter( string legacy ) => legacy.Trim() switch
	{
		"Aniso" => nameof( Nodes.PrismTextureFilter.Anisotropic ),
		"Bilinear" => nameof( Nodes.PrismTextureFilter.Bilinear ),
		"Trilinear" => nameof( Nodes.PrismTextureFilter.Trilinear ),
		"Point" => nameof( Nodes.PrismTextureFilter.Point ),
		_ => nameof( Nodes.PrismTextureFilter.Anisotropic )
	};

	/// <summary>
	/// <c>Editor.ShaderGraph.SamplerAddress</c> to <see cref="Nodes.PrismTextureAddress"/>.
	/// <c>Mirror_Once</c> has no Prism equivalent and becomes plain <c>Mirror</c>, which is what it
	/// degrades to outside the first repeat anyway.
	/// </summary>
	static string MapSamplerAddress( string legacy ) => legacy.Trim() switch
	{
		"Clamp" => nameof( Nodes.PrismTextureAddress.Clamp ),
		"Border" => nameof( Nodes.PrismTextureAddress.Border ),
		"Mirror" or "Mirror_Once" => nameof( Nodes.PrismTextureAddress.Mirror ),
		_ => nameof( Nodes.PrismTextureAddress.Wrap )
	};

	static bool ReadBool( JsonNode node, bool fallback = false )
	{
		if ( node is not JsonValue value ) return fallback;

		if ( value.TryGetValue<bool>( out var b ) ) return b;
		if ( value.TryGetValue<int>( out var i ) ) return i != 0;

		return fallback;
	}

	static void EnsureBuilt()
	{
		lock ( s_lock )
		{
			if ( s_mappings is not null ) return;

			s_mappings = BuildDefaults();
		}
	}

	static Dictionary<string, LegacyNodeMapping> BuildDefaults()
	{
		var table = new Dictionary<string, LegacyNodeMapping>( StringComparer.OrdinalIgnoreCase );

		void Add( string legacy, string prism, IReadOnlyDictionary<string, string> ports = null,
			IReadOnlyDictionary<string, string> properties = null, string note = null,
			Func<JsonObject, JsonObject> transform = null )
		{
			table[legacy] = new LegacyNodeMapping( legacy, prism )
			{
				Ports = ports,
				Properties = properties,
				Note = note,
				Transform = transform
			};
		}

		// Binary maths. The legacy plugs are A / B / Result throughout.
		var binary = Ports( ("A", "A"), ("B", "B"), ("Result", "Out") );

		Add( "Add", "prism.math.add", binary );
		Add( "Subtract", "prism.math.subtract", binary );
		Add( "Multiply", "prism.math.multiply", binary );
		Add( "Divide", "prism.math.divide", binary );
		Add( "Mod", "prism.math.modulo", binary );
		Add( "Power", "prism.math.power", binary );
		Add( "Min", "prism.math.min", Ports( ("InputA", "A"), ("InputB", "B"), ("Result", "Out") ) );
		Add( "Max", "prism.math.max", Ports( ("InputA", "A"), ("InputB", "B"), ("Result", "Out") ) );
		Add( "Lerp", "prism.math.lerp", Ports( ("A", "A"), ("B", "B"), ("C", "T"), ("Result", "Out") ) );
		Add( "RemapValue", "prism.math.remap",
			Ports( ("A", "In"), ("B", "InMin"), ("C", "InMax"), ("D", "OutMin"), ("E", "OutMax"), ("Result", "Out") ) );
		Add( "Arctan2", "prism.math.atan2", Ports( ("Y", "Y"), ("X", "X"), ("Result", "Out") ) );

		// Unary maths. The legacy plugs are Input / Result throughout.
		var unary = Ports( ("Input", "In"), ("Result", "Out") );

		Add( "Abs", "prism.math.abs", unary );
		Add( "Ceil", "prism.math.ceil", unary );
		Add( "Floor", "prism.math.floor", unary );
		Add( "Round", "prism.math.round", unary );
		Add( "Frac", "prism.math.frac", unary );
		Add( "Saturate", "prism.math.saturate", unary );
		Add( "OneMinus", "prism.math.oneMinus", unary );
		Add( "Sqrt", "prism.math.sqrt", unary );
		Add( "Exponential", "prism.math.exp", unary );
		Add( "BaseLog", "prism.math.log", unary );
		Add( "Sine", "prism.math.sin", unary );
		Add( "Cosine", "prism.math.cos", unary );
		Add( "Tan", "prism.math.tan", unary );
		Add( "Arcsin", "prism.math.asin", unary );
		Add( "Arccos", "prism.math.acos", unary );
		Add( "Length", "prism.vector.length", unary );
		Add( "Normalize", "prism.vector.normalize", unary );
		Add( "DDX", "prism.derivative.ddx", unary );
		Add( "DDY", "prism.derivative.ddy", unary );
		Add( "DDXY", "prism.derivative.fwidth", unary );
		Add( "Distance", "prism.vector.distance", binary );
		Add( "DotProduct", "prism.vector.dot", Ports( ("InputA", "A"), ("InputB", "B"), ("Result", "Out") ) );
		Add( "CrossProduct", "prism.vector.cross", binary );
		Add( "Reflection", "prism.vector.reflect", binary );
		Add( "Step", "prism.logic.step", Ports( ("Input", "In"), ("Edge", "Edge"), ("Result", "Out") ) );
		Add( "SmoothStep", "prism.logic.smoothstep",
			Ports( ("Input", "In"), ("Edge1", "Low"), ("Edge2", "High"), ("Result", "Out") ) );
		Add( "Branch", "prism.logic.branch",
			Ports( ("True", "True"), ("False", "False"), ("Predicate", "Condition"), ("Result", "Out") ),
			null, "The branch condition became a literal. A named branch keeps the state its attribute " +
				"defaulted to; re-wire Condition to make it switchable again.", CarryBranch );

		// Channels.
		Add( "SplitVector", "prism.channel.split", Ports( ("Input", "In") ) );
		Add( "CombineVector", "prism.channel.combine",
			Ports( ("X", "X"), ("Y", "Y"), ("Z", "Z"), ("W", "W"), ("XYZW", "XYZW"), ("XYZ", "XYZ"), ("XY", "XY") ) );
		Add( "SwizzleVector", "prism.channel.swizzle", Ports( ("Input", "In"), ("Output", "Out") ) );
		Add( "AppendVector", "prism.channel.append", Ports( ("A", "A"), ("B", "B"), ("Output", "Out") ) );

		// Colour.
		Add( "RGBtoHSV", "prism.color.rgbToHsv", unary );
		Add( "HSVtoRGB", "prism.color.hsvToRgb", unary );
		Add( "SrgbGammaToLinear", "prism.color.gammaToLinear", unary );
		Add( "SrgbLinearToGamma", "prism.color.linearToGamma", unary );
		Add( "Blend", "prism.blend.mix", Ports( ("A", "A"), ("B", "B"), ("C", "T"), ("Result", "Out") ) );
		Add( "NormalBlend", "prism.blend.normal", binary );

		// UV and transforms.
		Add( "TextureCoord", "prism.input.texcoord", Ports( ("Result", "UV") ), null, null, CarryTexCoord );
		Add( "TileAndOffset", "prism.uv.tileAndOffset",
			Ports( ("Coords", "UV"), ("Tile", "Tile"), ("Offset", "Offset"), ("Result", "Out") ) );
		Add( "PolarCoordinates", "prism.uv.polar",
			Ports( ("Coords", "UV"), ("Center", "Center"), ("RadialScale", "RadialScale"),
				("LengthScale", "LengthScale"), ("Result", "Out") ) );
		Add( "ApplyTrs", "prism.transform.trs",
			Ports( ("Vector", "In"), ("Translation", "Translation"), ("Rotation", "Rotation"),
				("Scale", "Scale"), ("Result", "Out") ) );
		Add( "TransformNormal", "prism.transform.normal", unary );

		// Textures. Every one of these is a TextureSamplerBase in the built-in editor, so they all arrive
		// with the same Image / Sampler / UI trio and all need CarryTextureSlot to unpack it.
		Add( "TextureSampler", "prism.texture.sample2d",
			Ports( ("Coords", "UV"), ("Result", "RGBA"), ("R", "R"), ("G", "G"), ("B", "B"), ("A", "A") ),
			null, "Sampler settings and the image reference were carried over; check the colour space.",
			CarryTextureSlot );
		Add( "TextureCube", "prism.texture.sampleCube",
			Ports( ("Coords", "UV"), ("Result", "RGBA"), ("R", "R"), ("G", "G"), ("B", "B"), ("A", "A") ),
			null, null, CarryTextureSlot );
		Add( "TextureTriplanar", "prism.texture.triplanar",
			Ports( ("Coords", "Position"), ("Normal", "Normal"), ("Result", "RGBA") ),
			null, null, CarryTextureSlot );
		Add( "NormapMapTriplanar", "prism.texture.triplanarNormal",
			Ports( ("Coords", "Position"), ("Normal", "Normal"), ("Result", "XYZ") ),
			null, null, CarryTextureSlot );

		// Noise.
		Add( "ValueNoise", "prism.noise.value", Ports( ("Coords", "UV"), ("Result", "Out") ) );
		Add( "SimplexNoise", "prism.noise.simplex", Ports( ("Coords", "UV"), ("Result", "Out") ) );
		Add( "VoronoiNoise", "prism.noise.voronoi", Ports( ("Coords", "UV"), ("Result", "Out") ) );
		Add( "FuzzyNoise", "prism.noise.fuzzy", Ports( ("Coords", "UV"), ("Result", "Out") ) );

		// Engine inputs.
		Add( "WorldNormal", "prism.input.worldNormal" );
		Add( "WorldPosition", "prism.input.worldPosition" );
		Add( "WorldTangent", "prism.input.worldTangent" );
		Add( "ObjectPosition", "prism.input.objectPosition" );
		Add( "ObjectSpaceNormal", "prism.input.objectNormal" );
		Add( "ScreenPosition", "prism.input.screenPosition" );
		Add( "ScreenCoordinate", "prism.input.screenCoordinate" );
		Add( "ViewDirection", "prism.input.viewDirection" );
		Add( "VertexColor", "prism.input.vertexColor" );
		Add( "Tint", "prism.input.tint" );
		Add( "IsFrontFace", "prism.input.isFrontFace" );
		Add( "Camera", "prism.input.camera" );
		Add( "Time", "prism.input.time" );
		Add( "ViewportNode", "prism.input.viewport" );
		Add( "Sun", "prism.lighting.sun" );
		Add( "SceneColor", "prism.input.sceneColor" );
		Add( "Depth", "prism.input.depth" );
		Add( "LinearDepth", "prism.input.linearDepth" );
		Add( "Fresnel", "prism.input.fresnel" );
		Add( "TangentViewVector", "prism.input.tangentViewVector" );

		// Constants and structure.
		// Unnamed constants. A named one is promoted to a blackboard parameter instead and never reaches
		// these mappings. The component plugs have to be listed or an edge leaving one is dropped: the
		// built-in Float4 is a ParameterNode<Color> and spells them R/G/B/A, the narrower ones X/Y/Z.
		Add( "Float", "prism.parameter.constant", Ports( ("Result", "Out") ),
			null, null, CarryConstant( nameof( Nodes.PrismConstantKind.Float ), "ScalarValue" ) );
		Add( "Float2", "prism.parameter.constant",
			Ports( ("Result", "Out"), ("X", "X"), ("Y", "Y") ),
			null, null, CarryConstant( nameof( Nodes.PrismConstantKind.Float2 ), "Vector2Value" ) );
		Add( "Float3", "prism.parameter.constant",
			Ports( ("Result", "Out"), ("X", "X"), ("Y", "Y"), ("Z", "Z") ),
			null, null, CarryConstant( nameof( Nodes.PrismConstantKind.Float3 ), "Vector3Value" ) );
		Add( "Float4", "prism.parameter.constant",
			Ports( ("Result", "Out"), ("R", "R"), ("G", "G"), ("B", "B"), ("A", "A") ),
			null, null, CarryConstant( nameof( Nodes.PrismConstantKind.Color ), "ColorValue" ) );
		Add( "Reroute", "prism.util.reroute", Ports( ("Input", "In"), ("Result", "Out") ) );
		Add( "CommentNode", "prism.util.comment" );

		table["Result"] = new LegacyNodeMapping( "Result", "prism.output.surface" )
		{
			Ports = Ports(
				("Albedo", "Albedo"), ("Emission", "Emission"), ("Opacity", "Opacity"), ("Normal", "Normal"),
				("Roughness", "Roughness"), ("Metalness", "Metalness"),
				("AmbientOcclusion", "AmbientOcclusion"), ("PositionOffset", "PositionOffset") ),
			Transform = MoveOutputDefaultsToInline,
			Note = "The output node's Default* properties became inline literals on the matching ports."
		};

		Add( "FunctionResult", "prism.subgraph.output" );
		Add( "SubgraphInput", "prism.subgraph.input" );
		Add( "SubgraphNode", "prism.subgraph.instance" );

		return table;
	}

	/// <summary>
	/// The built-in output node stores an unconnected port's value in a <c>DefaultX</c> property.
	/// Prism stores it as the port's inline literal, so move it there and drop the property.
	/// </summary>
	static JsonObject MoveOutputDefaultsToInline( JsonObject node )
	{
		if ( node?["props"] is not JsonObject props ) return node;

		var inline = node["inline"] as JsonObject ?? new JsonObject();

		foreach ( var key in props.Select( x => x.Key ).ToArray() )
		{
			if ( !key.StartsWith( "Default", StringComparison.Ordinal ) || key.Length <= 7 ) continue;

			var port = key[7..];
			var value = props[key];

			props.Remove( key );

			if ( value is null ) continue;

			inline[port] = value.DeepClone();
		}

		if ( inline.Count > 0 ) node["inline"] = inline;

		return node;
	}

	static Dictionary<string, string> Ports( params (string From, string To)[] pairs )
	{
		var map = new Dictionary<string, string>( StringComparer.Ordinal );

		foreach ( var (from, to) in pairs )
		{
			map[from] = to;
		}

		return map;
	}

	static Dictionary<string, string> Properties( params (string From, string To)[] pairs )
	{
		var map = new Dictionary<string, string>( StringComparer.Ordinal );

		foreach ( var (from, to) in pairs )
		{
			map[from] = to;
		}

		return map;
	}

	/// <summary>
	/// The type-id prefix an unmapped legacy node keeps. It is deliberately unresolvable, so the node
	/// becomes a placeholder holding its original data rather than being guessed at.
	/// </summary>
	public const string LegacyTypePrefix = "sbox.shadergraph.";

	/// <summary>The node type a promoted blackboard parameter is referenced through.</summary>
	public const string ParameterRefTypeId = "prism.parameter.ref";

	static readonly string HlslDialectName = HlslDialect.SboxSlang.ToString();

	/// <summary>
	/// Legacy classes that become a blackboard parameter plus a reference node.
	/// <remarks>
	/// <c>TextureSampler</c> is deliberately absent even though it is a named parameter in the built-in
	/// editor. It is a parameter <em>and</em> a sampler in one node: promoting it yields a bare
	/// <c>prism.parameter.ref</c>, which outputs a texture object rather than a sampled colour, and the
	/// edges leaving its <c>Result</c> plug are remapped to an <c>RGBA</c> port the reference node does
	/// not have. Prism's own texture nodes already model this exactly — a self-declared slot with a name,
	/// a group and a default asset — so the sampler mapping plus <see cref="CarryTextureSlot"/> is both
	/// the closer equivalent and the one that keeps the graph connected.
	/// </remarks>
	/// </summary>
	static readonly HashSet<string> s_parameterClasses = new( StringComparer.Ordinal )
	{
		"Float", "Float2", "Float3", "Float4"
	};

	static readonly HashSet<string> s_reservedKeys = new( StringComparer.Ordinal )
	{
		"_class", "Identifier", "Position", "ExpandSize"
	};

	/// <summary>
	/// Keys a legacy parameter node carries that mean nothing on a Prism constant. They are the editor
	/// affordances of a node that could be promoted to a material parameter; an unnamed one was not, so
	/// they describe a slot that does not exist. Removed rather than left to be dropped silently, so the
	/// imported document says what it means.
	/// </summary>
	static readonly string[] s_legacyParameterKeys =
	{
		"Value", "Name", "IsAttribute", "UI", "Min", "Max"
	};

	/// <summary>Keys a legacy branch carries that <c>CarryBranch</c> has already folded into a literal.</summary>
	static readonly string[] s_legacyBranchKeys =
	{
		"Name", "Enabled", "IsAttribute", "UI", "Operator"
	};

	/// <summary>Prism type id to its <c>[InlineValue]</c> property-to-port bindings. See <see cref="InlineBindings"/>.</summary>
	static readonly Dictionary<string, IReadOnlyList<(string Property, string Port)>> s_inlineBindings =
		new( StringComparer.Ordinal );
}