Editor/Prism/Serialization/PrismSerializer.cs

Editor-side serialization for Prism graphs and node property reflection. Handles reading/writing .prism documents and fragments, JSON parsing with duplicate-key safety, node/edge/parameter/keyword/group/note read/write, schema migrations, per-node property reflection cache, inline literal syncing, hashing for structural/value changes, and a small self-test node type.

ReflectionFile Access
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Undo;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Text;

namespace Editor.Prism.Serialization;

/// <summary>
/// Reflection over a node's serialized properties, and the cache that makes it cheap.
/// <para>
/// A node's <em>structural</em> properties are the ones that change generated code — enums, modes,
/// asset references — and go into the document's <c>props</c> object. Its <em>inline</em> properties
/// are the ones marked <c>[InlineValue]</c>, which supply the literal for an unconnected port and go
/// into <c>inline</c> keyed by port id. Splitting them is what lets the compile service tell a slider
/// drag (re-push a uniform) from an enum change (recompile).
/// </para>
/// </summary>
public static class NodeProperties
{
	sealed class Reflected
	{
		public PropertyInfo[] Structural;
		public PropertyInfo[] Inline;
		public Dictionary<string, PropertyInfo> ByName;
		public PrismNode Prototype;
	}

	static readonly object s_lock = new();
	static readonly Dictionary<Type, Reflected> s_cache = new();

	/// <summary>Drop the reflection cache. Must run on hotload.</summary>
	public static void Flush()
	{
		lock ( s_lock )
		{
			s_cache.Clear();
		}
	}

	/// <summary>Properties written to a node's <c>props</c> object, in a stable order.</summary>
	public static IReadOnlyList<PropertyInfo> Structural( Type type ) => Describe( type ).Structural;

	/// <summary>Properties marked <c>[InlineValue]</c>, which back a port's unconnected literal.</summary>
	public static IReadOnlyList<PropertyInfo> Inline( Type type ) => Describe( type ).Inline;

	/// <summary>A shared default-constructed instance, used to decide whether a value is worth writing.</summary>
	public static PrismNode Prototype( Type type ) => Describe( type ).Prototype;

	/// <summary>Find a serialized property by name, following <c>[FormerlyKnownAs]</c> and ignoring case.</summary>
	public static PropertyInfo Find( Type type, string name )
	{
		if ( string.IsNullOrEmpty( name ) ) return null;

		return Describe( type ).ByName.TryGetValue( name, out var property ) ? property : null;
	}

	/// <summary>Read a property value off a node. Returns null rather than throwing.</summary>
	public static object Get( PrismNode node, string name )
	{
		if ( node is null ) return null;

		var property = Find( node.GetType(), name );

		if ( property is null || !property.CanRead ) return null;

		return PrismLog.Guard<object>( $"Read property '{name}'", () => property.GetValue( node ) );
	}

	/// <summary>Write a property value onto a node, converting where it safely can. Never throws.</summary>
	public static bool Set( PrismNode node, string name, object value )
	{
		if ( node is null ) return false;

		var property = Find( node.GetType(), name );

		if ( property is null || !property.CanWrite ) return false;

		return PrismLog.Guard( $"Write property '{name}'", () =>
		{
			property.SetValue( node, Convert( value, property.PropertyType ) );
		} );
	}

	/// <summary>Serialize a node's structural properties into a <c>props</c> object.</summary>
	public static JsonObject Write( PrismNode node, DiagnosticSink sink = null )
	{
		var props = new JsonObject();

		if ( node is null ) return props;

		foreach ( var property in Structural( node.GetType() ) )
		{
			PrismLog.Try( $"Serialize '{node.GetType().Name}.{property.Name}'", () =>
			{
				var value = property.GetValue( node );
				props[property.Name] = value is null
					? null
					: JsonSerializer.SerializeToNode( value, property.PropertyType, PrismJson.Options );
			}, sink, DiagnosticCode.NodeReadFailed, GraphRef.ForNode( node.Id ) );
		}

		return props;
	}

	/// <summary>
	/// Apply a <c>props</c> object to a node, one property at a time. A property that fails to convert
	/// is reported and skipped — one bad field never costs the whole node.
	/// </summary>
	public static void Read( PrismNode node, JsonObject props, DiagnosticSink sink = null )
	{
		if ( node is null || props is null ) return;

		foreach ( var pair in props )
		{
			var property = Find( node.GetType(), pair.Key );

			if ( property is null || !property.CanWrite ) continue;

			PrismLog.Try( $"Deserialize '{node.GetType().Name}.{pair.Key}'", () =>
			{
				if ( pair.Value is null )
				{
					if ( !property.PropertyType.IsValueType ) property.SetValue( node, null );
					return;
				}

				var value = pair.Value.Deserialize( property.PropertyType, PrismJson.Options );
				property.SetValue( node, value );
			}, sink, DiagnosticCode.NodeReadFailed, GraphRef.ForNode( node.Id ) );
		}
	}

	/// <summary>
	/// Ask a node to rebuild its ports now that its properties have been deserialized. Nodes whose
	/// port set depends on their properties need this or they load with the constructor's port set.
	/// </summary>
	public static void RebuildPorts( PrismNode node )
	{
		if ( node is null ) return;

		if ( node is UnknownNode unknown )
		{
			unknown.Rebuild();
			return;
		}

		PrismLog.Guard( $"Rebuild ports of '{node.GetType().Name}'", node.RebuildPortsAfterLoad );
	}

	/// <summary>The inline literal of a port: the bound property when there is one, otherwise the slot.</summary>
	public static object GetInline( PrismNode node, InputPort port )
	{
		if ( node is null || port is null ) return null;

		var bound = port.Def?.InlineValueProperty;

		if ( !string.IsNullOrEmpty( bound ) )
		{
			var value = Get( node, bound );

			if ( value is not null ) return value;
		}

		return port.InlineValue;
	}

	/// <summary>Set a port's inline literal, keeping the bound property and the port slot in step.</summary>
	public static void SetInline( PrismNode node, InputPort port, object value )
	{
		if ( port is null ) return;

		port.InlineValue = value;

		var bound = port.Def?.InlineValueProperty;

		if ( string.IsNullOrEmpty( bound ) ) return;

		Set( node, bound, value );
	}

	/// <summary>
	/// Put every inline literal back to what a freshly constructed node of this type would have.
	/// <para>
	/// Essential for undo restore, which reuses a live node instead of rebuilding it: inline values are
	/// written to a document only when they differ from the default, so a snapshot that omits one means
	/// "this is the default", not "leave whatever is there". Without this reset, undoing a slider drag
	/// would leave the slider where it was.
	/// </para>
	/// </summary>
	public static void ResetInline( PrismNode node )
	{
		if ( node is null || node is UnknownNode ) return;

		var type = node.GetType();
		var prototype = Prototype( type );

		if ( prototype is not null )
		{
			foreach ( var property in Inline( type ) )
			{
				PrismLog.Guard( $"Reset '{type.Name}.{property.Name}'",
					() => property.SetValue( node, property.GetValue( prototype ) ) );
			}
		}

		foreach ( var port in node.Inputs )
		{
			port.InlineValue = null;
		}
	}

	/// <summary>
	/// Put every structural property of a node back to what a freshly constructed one would hold.
	/// <para>
	/// The mirror of <see cref="ResetInline"/>, and needed for the same reason: undo restore reuses the
	/// live node object rather than building a new one, so a property the snapshot does not mention has
	/// to go back to its default. Without this, applying a document that omits a property leaves
	/// whatever the node happened to have — which is not what reading that same document into a fresh
	/// node produces, and undo is only trustworthy while those two agree.
	/// </para>
	/// </summary>
	public static void ResetProperties( PrismNode node )
	{
		if ( node is null || node is UnknownNode ) return;

		var type = node.GetType();
		var prototype = Prototype( type );

		if ( prototype is null ) return;

		foreach ( var property in Structural( type ) )
		{
			if ( !property.CanWrite ) continue;

			PrismLog.Guard( $"Reset '{type.Name}.{property.Name}'",
				() => property.SetValue( node, property.GetValue( prototype ) ) );
		}
	}

	/// <summary>
	/// Copy the inline-bound property values of a node into its port slots. Run after deserialization
	/// so a node whose literals live in properties still reports them through its ports.
	/// </summary>
	public static void SyncInlineFromProperties( PrismNode node )
	{
		if ( node is null ) return;

		foreach ( var port in node.Inputs )
		{
			var bound = port.Def?.InlineValueProperty;

			if ( string.IsNullOrEmpty( bound ) ) continue;
			if ( port.InlineValue is not null ) continue;

			port.InlineValue = Get( node, bound );
		}
	}

	/// <summary>
	/// Push one property's value into the port literal it backs, when it backs one.
	/// <para>
	/// <c>NodeEmitter</c> reads the port slot before the bound property, so an edit that writes only the
	/// property — which is what the Inspector's control sheet does — would not reach the generated
	/// shader until the document was reloaded. Returns true when a slot was updated.
	/// </para>
	/// </summary>
	public static bool SyncInlineFor( PrismNode node, string property )
	{
		if ( node is null || string.IsNullOrEmpty( property ) ) return false;

		var resolved = Find( node.GetType(), property );

		if ( resolved is null || !resolved.CanRead ) return false;

		var updated = false;

		foreach ( var port in node.Inputs )
		{
			if ( !string.Equals( port.Def?.InlineValueProperty, resolved.Name, StringComparison.Ordinal ) ) continue;

			port.InlineValue = PrismLog.Guard<object>( $"Read property '{resolved.Name}'",
				() => resolved.GetValue( node ) );

			updated = true;
		}

		return updated;
	}

	/// <summary>Hash of everything about a node that changes generated code.</summary>
	public static ulong StructuralHash( PrismNode node )
	{
		if ( node is null ) return 0UL;

		if ( node is UnknownNode unknown )
		{
			return Fnv.Mix( Fnv.Of( unknown.TypeId ), unknown.Raw?.ToJsonString() ?? string.Empty );
		}

		var hash = Fnv.Offset;

		foreach ( var property in Structural( node.GetType() ) )
		{
			hash = Fnv.Mix( hash, property.Name );
			hash = Fnv.Mix( hash, Fnv.OfValue( PrismLog.Guard<object>( "hash property",
				() => property.GetValue( node ) ) ) );
		}

		return hash;
	}

	/// <summary>Hash of everything about a node that only changes uniform values.</summary>
	public static ulong ValueHash( PrismNode node )
	{
		if ( node is null ) return 0UL;
		if ( node is UnknownNode ) return 0UL;

		var hash = Fnv.Offset;

		foreach ( var property in Inline( node.GetType() ) )
		{
			hash = Fnv.Mix( hash, property.Name );
			hash = Fnv.Mix( hash, Fnv.OfValue( PrismLog.Guard<object>( "hash property",
				() => property.GetValue( node ) ) ) );
		}

		return hash;
	}

	static object Convert( object value, Type target )
	{
		if ( value is null ) return target.IsValueType ? Activator.CreateInstance( target ) : null;
		if ( target.IsInstanceOfType( value ) ) return value;

		if ( target.IsEnum )
		{
			if ( value is string name && Enum.TryParse( target, name, true, out var parsed ) ) return parsed;

			return Enum.ToObject( target, System.Convert.ToInt64( value ) );
		}

		if ( value is JsonNode json ) return json.Deserialize( target, PrismJson.Options );

		// Prism's id structs are not IConvertible, so ChangeType would throw on both directions of the
		// one conversion that matters: a caller handing a raw id string to a ParamId-typed property, or
		// vice versa. Both shapes reach here from the UI, so both are handled.
		if ( value is string text )
		{
			if ( target == typeof( ParamId ) ) return ParamId.Parse( text );
			if ( target == typeof( NodeId ) ) return NodeId.Parse( text );
			if ( target == typeof( PortId ) ) return PortId.Parse( text );
			if ( target == typeof( EdgeId ) ) return EdgeId.Parse( text );
		}

		if ( target == typeof( string ) && value is ParamId or NodeId or PortId or EdgeId )
		{
			return value.ToString();
		}

		if ( TryReshape( value, target, out var reshaped ) ) return reshaped;

		return System.Convert.ChangeType( value, target, CultureInfo.InvariantCulture );
	}

	/// <summary>
	/// Convert between the numeric shapes the model actually stores: <c>float</c>, <c>Vector2/3/4</c> and
	/// <c>Color</c>, in any direction.
	/// <para>
	/// This is not a nicety. A port declares a shader type and its <c>[InlineValue]</c> property declares
	/// a CLR type, and the two do not have to be the same shape — a <c>float3</c> port bound to a
	/// <c>Color</c> property is the single most common spelling in the node library. Reading a document
	/// hands the literal back in the shape the <em>port</em> implies, and <c>ChangeType</c> cannot turn a
	/// <c>Vector3</c> into a <c>Color</c>: it throws, the guard swallows it, and the property silently
	/// keeps its default. The literal was written to disk correctly and thrown away on the way back in —
	/// the user's colour reverts to white the first time they reopen the graph, with nothing said.
	/// </para>
	/// <para>
	/// The reshaping rules are the wire's rules, so a value never changes meaning by crossing this
	/// boundary: a scalar splats, a narrower value pads (with 1 in the fourth component, matching
	/// <see cref="TypeRules.DefaultFill"/>, so a colour stays opaque), and a wider one truncates.
	/// </para>
	/// </summary>
	static bool TryReshape( object value, Type target, out object result )
	{
		result = null;

		var wantsWide = target == typeof( Color ) || target == typeof( Vector2 ) ||
			target == typeof( Vector3 ) || target == typeof( Vector4 );

		var isWide = value is Vector2 or Vector3 or Vector4 or Color or float[];

		if ( !wantsWide && !isWide ) return false;

		var components = ValueCodec.ToFloats( value );

		if ( components.Length == 0 ) return false;

		if ( wantsWide )
		{
			if ( target == typeof( Vector2 ) ) result = ValueCodec.ToComponents( components, 2 );
			else if ( target == typeof( Vector3 ) ) result = ValueCodec.ToComponents( components, 3 );
			else
			{
				var wide = (Vector4)ValueCodec.ToComponents( components, 4 );

				result = target == typeof( Vector4 ) ? wide : new Color( wide.x, wide.y, wide.z, wide.w );
			}

			return true;
		}

		// A wide value handed to a scalar property keeps its first component, the same truncation the
		// type rules apply on a wire.
		if ( target == typeof( float ) ) result = components[0];
		else if ( target == typeof( double ) ) result = (double)components[0];
		else if ( target == typeof( int ) ) result = (int)components[0];
		else if ( target == typeof( bool ) ) result = components[0] != 0f;
		else return false;

		return true;
	}

	static Reflected Describe( Type type )
	{
		if ( type is null ) return s_empty;

		lock ( s_lock )
		{
			if ( s_cache.TryGetValue( type, out var cached ) ) return cached;
		}

		var structural = new List<PropertyInfo>();
		var inline = new List<PropertyInfo>();
		var byName = new Dictionary<string, PropertyInfo>( StringComparer.OrdinalIgnoreCase );

		if ( !typeof( UnknownNode ).IsAssignableFrom( type ) )
		{
			var properties = type
				.GetProperties( BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy )
				.Where( IsSerializable )
				.OrderBy( x => x.Name, StringComparer.Ordinal )
				.ToArray();

			foreach ( var property in properties )
			{
				if ( property.GetCustomAttribute<InlineValueAttribute>() is not null ) inline.Add( property );
				else structural.Add( property );

				byName[property.Name] = property;

				foreach ( var former in property.GetCustomAttributes<FormerlyKnownAsAttribute>() )
				{
					if ( string.IsNullOrEmpty( former.OldName ) ) continue;
					if ( byName.ContainsKey( former.OldName ) ) continue;

					byName[former.OldName] = property;
				}
			}
		}

		var reflected = new Reflected
		{
			Structural = structural.ToArray(),
			Inline = inline.ToArray(),
			ByName = byName,
			Prototype = PrismLog.Guard<PrismNode>( $"Prototype '{type.FullName}'",
				() => Activator.CreateInstance( type ) as PrismNode )
		};

		lock ( s_lock )
		{
			s_cache[type] = reflected;
		}

		return reflected;
	}

	static bool IsSerializable( PropertyInfo property )
	{
		if ( property.GetIndexParameters().Length > 0 ) return false;
		if ( !property.CanRead || !property.CanWrite ) return false;
		if ( property.GetMethod is null || !property.GetMethod.IsPublic ) return false;
		if ( property.SetMethod is null || !property.SetMethod.IsPublic ) return false;
		if ( property.PropertyType == typeof( PortRef ) ) return false;
		if ( property.GetCustomAttribute<JsonIgnoreAttribute>() is not null ) return false;

		var declaring = property.DeclaringType;

		if ( declaring is null ) return false;
		if ( declaring == typeof( PrismNode ) || declaring == typeof( object ) ) return false;

		return true;
	}

	static readonly Reflected s_empty = new()
	{
		Structural = Array.Empty<PropertyInfo>(),
		Inline = Array.Empty<PropertyInfo>(),
		ByName = new Dictionary<string, PropertyInfo>( StringComparer.OrdinalIgnoreCase )
	};
}

/// <summary>An enum property, so the self-test covers enum round-tripping.</summary>
public enum SelfTestMode
{
	/// <summary>First value.</summary>
	Alpha,
	/// <summary>Second value.</summary>
	Beta,
	/// <summary>Third value.</summary>
	Gamma
}

/// <summary>
/// The node type <see cref="PrismSerializer.RunSelfTest"/> exercises the registered-node path with.
/// <para>
/// It exists because there is no test framework here and the alternative — verifying serialization
/// only against node types owned by other packages — would mean this package could not prove itself
/// until the whole editor was assembled. It declares one of everything the serializer has to handle:
/// a string, an int, an enum, a bool, two inline-bound literals of different widths, and a port set
/// that changes with a property.
/// </para>
/// <para>
/// It is tiered <see cref="NodeTier.Deprecated"/>, so it is registered and constructible but never
/// offered in the palette or the library tree.
/// </para>
/// </summary>
[NodeInfo( Id = PrismSelfTestNode.TypeId, Title = "Prism Self-Test Probe", Category = "Utility/Internal",
	Icon = "science", Tier = NodeTier.Deprecated,
	Description = "Internal probe used by the serializer self-test. Not intended for use in graphs." )]
[NodeVersion( 1 )]
public sealed class PrismSelfTestNode : PrismNode
{
	/// <summary>The stable type id this probe is registered under.</summary>
	public const string TypeId = "prism.internal.selftest";

	/// <summary>A scalar input.</summary>
	[In( "float", Name = "A" )] public PortRef A { get; set; }

	/// <summary>A vector input.</summary>
	[In( "float3", Name = "B" )] public PortRef B { get; set; }

	/// <summary>
	/// A second vector input, whose inline literal is stored as a <see cref="Color"/> rather than a
	/// <c>Vector3</c>. The mismatch is the point: it is the commonest spelling in the node library and
	/// the shape the literal comes back in from a document is the port's, not the property's.
	/// </summary>
	[In( "float3", Name = "C" )] public PortRef C { get; set; }

	/// <summary>A vector output.</summary>
	[Out( "float3", Name = "Out" )] public PortRef Out { get; set; }

	/// <summary>The literal used when <c>A</c> is unconnected.</summary>
	[InlineValue( nameof( A ) )] public float DefaultA { get; set; } = 0.5f;

	/// <summary>The literal used when <c>B</c> is unconnected.</summary>
	[InlineValue( nameof( B ) )] public Vector3 DefaultB { get; set; } = new( 1f, 2f, 3f );

	/// <summary>The literal used when <c>C</c> is unconnected, deliberately a different shape to its port.</summary>
	[InlineValue( nameof( C ) )] public Color DefaultC { get; set; } = Color.White;

	/// <summary>A string property.</summary>
	public string Label { get; set; } = "probe";

	/// <summary>An integer property.</summary>
	public int Channel { get; set; } = 2;

	/// <summary>An enum property.</summary>
	public SelfTestMode Mode { get; set; } = SelfTestMode.Beta;

	/// <summary>A boolean property.</summary>
	public bool Enabled { get; set; } = true;

	/// <summary>How many extra input ports to grow. Drives a port rebuild after deserialization.</summary>
	public int ExtraPorts { get; set; }

	/// <summary>Drop the <c>B</c> port, to prove a vanished port produces a ghost rather than a deletion.</summary>
	public bool HideB { get; set; }

	/// <inheritdoc/>
	protected override void OnDefinePorts( PortBuilder b )
	{
		if ( HideB ) b.Remove( nameof( B ) );

		for ( int i = 0; i < ExtraPorts; i++ )
		{
			b.Input( $"Extra{i}", "float", $"Extra {i}" );
		}
	}

	/// <inheritdoc/>
	public override void Emit( EmitContext ctx )
	{
		ctx?.Out( nameof( Out ), ctx.Const( new Vector3( DefaultA, DefaultA, DefaultA ) ) );
	}
}

/// <summary>
/// Reads and writes <c>.prism</c> / <c>.prismfn</c> documents.
/// <para>
/// Three rules govern everything here. <b>Reading is fault-isolated per node and per edge</b> — only
/// a JSON parse failure aborts, and every recovery emits a diagnostic. <b>Writing is deterministic</b>
/// — stable key order, two-space indent, exact float formatting — so regenerating an unchanged
/// document produces a byte-identical file. <b>Nothing is ever silently discarded</b> — unrecognised
/// node types round-trip verbatim, unresolvable edges become visible ghosts, and every section
/// carries an <c>x</c> bag for keys a future schema invents.
/// </para>
/// </summary>
public static class PrismSerializer
{
	const string KeySchema = "schema";
	const string KeyId = "id";
	const string KeyKind = "kind";

	static readonly JsonDocumentOptions s_documentOptions = new()
	{
		AllowTrailingCommas = true,
		CommentHandling = JsonCommentHandling.Skip,

		// Deliberately the same limit the writer uses: a document we can open has to be one we can save.
		MaxDepth = PrismJson.MaxDepth
	};

	// ---------------------------------------------------------------- reading ----

	/// <summary>
	/// Read a document. Returns null only when the text is not JSON at all; every other failure
	/// degrades to a diagnostic and a partially recovered document.
	/// </summary>
	public static PrismGraph Read( string json, DiagnosticSink sink = null )
	{
		TryRead( json, out var graph, sink );
		return graph;
	}

	/// <summary>Read a document, reporting whether the text parsed.</summary>
	public static bool TryRead( string json, out PrismGraph graph, DiagnosticSink sink = null )
	{
		graph = null;

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

		JsonObject root = null;

		try
		{
			// Parsed through JsonDocument and rebuilt rather than handed straight to JsonNode.Parse.
			// A JsonNode tree materialises its property dictionaries lazily and throws
			// ArgumentException the first time anything reads an object that declares the same key
			// twice — from inside whichever reader happened to touch it first, long past any catch
			// around the parse. A document with a duplicated key is what a bad three-way merge
			// produces, which is exactly when it matters that the file still opens.
			root = ParseObject( json );
		}
		catch ( Exception e )
		{
			sink?.Error( DiagnosticCode.SectionReadFailed, $"The document is not valid JSON: {e.Message}",
				null, e.ToString() );
			return false;
		}

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

		graph = ReadJson( root, sink );
		return graph is not null;
	}

	/// <summary>
	/// Read a document from an already-parsed JSON object.
	/// <para>
	/// The caller may have built that object any way at all — the asset system hands one over, a
	/// migration step synthesises one — so a section that fails takes the sections after it with it
	/// rather than the whole document: what has been recovered so far is still returned, with a
	/// diagnostic saying where reading stopped.
	/// </para>
	/// </summary>
	public static PrismGraph ReadJson( JsonObject root, DiagnosticSink sink = null )
	{
		if ( root is null ) return null;

		root = PrismLog.Guard( "Upgrading the document schema", () => SchemaMigrations.Upgrade( root, sink ) ) ?? root;

		var kind = PrismLog.Guard<string>( "Reading the document kind", () => ValueCodec.StringOf( root[KeyKind] ) );
		var graph = new PrismGraph( string.Equals( kind, PrismConstants.DocumentKindSubgraph,
			StringComparison.OrdinalIgnoreCase ) );

		using ( graph.SuspendEvents() )
		{
			PrismLog.Try( "Reading the document", () =>
			{
				graph.DocumentId = ValueCodec.StringOf( root[KeyId] ) ?? Ids.NewShortId();

				ReadMeta( graph, root["meta"] as JsonObject );
				ReadSettings( graph, root["settings"] as JsonObject, sink );
				ReadPreview( graph, root["preview"] as JsonObject );
				ReadParameters( graph, root["parameters"] as JsonArray, sink );
				ReadKeywords( graph, root["keywords"] as JsonArray, sink );
				ReadNodes( graph, root["nodes"] as JsonArray, sink );

				var edges = ParseEdges( root["edges"] as JsonArray, sink );

				foreach ( var node in graph.Nodes )
				{
					if ( node is UnknownNode unknown ) unknown.RebuildFromEdges( edges.Select( x => x.Edge ) );
				}

				ResolveEdges( graph, edges, sink );

				ReadGroups( graph, root["groups"] as JsonArray, sink );
				ReadNotes( graph, root["notes"] as JsonArray, sink );
				ReadView( graph, root["view"] as JsonObject );

				graph.X = ExtraBag( root, s_documentKeys );
			}, sink, DiagnosticCode.SectionReadFailed );
		}

		if ( string.IsNullOrEmpty( graph.DocumentId ) ) graph.DocumentId = Ids.NewShortId();

		graph.IsDirty = false;

		return graph;
	}

	/// <summary>
	/// Parse a JSON object the safe way: through <see cref="JsonDocument"/> and
	/// <see cref="Materialize"/>, so a duplicated property name resolves last-wins here instead of
	/// throwing out of whichever reader touches it first. Every entry point that takes raw text goes
	/// through this — the whole document, a clipboard fragment and an undo snapshot alike.
	/// </summary>
	static JsonObject ParseObject( string json )
	{
		using var document = JsonDocument.Parse( StripPreamble( json ), s_documentOptions );

		return Materialize( document.RootElement, 0 ) as JsonObject;
	}

	/// <summary>
	/// Rebuild a parsed element as a node tree, resolving a duplicated property name to its last value
	/// rather than throwing — the same rule <c>JsonSerializer</c> applies, and the only one that lets a
	/// hand-merged document open at all.
	/// </summary>
	static JsonNode Materialize( JsonElement element, int depth )
	{
		if ( depth > PrismJson.MaxDepth ) return null;

		switch ( element.ValueKind )
		{
			case JsonValueKind.Object:
			{
				var json = new JsonObject();

				foreach ( var property in element.EnumerateObject() )
				{
					json[property.Name] = Materialize( property.Value, depth + 1 );
				}

				return json;
			}

			case JsonValueKind.Array:
			{
				var array = new JsonArray();

				foreach ( var item in element.EnumerateArray() )
				{
					array.Add( Materialize( item, depth + 1 ) );
				}

				return array;
			}

			case JsonValueKind.Null:
			case JsonValueKind.Undefined:
				return null;

			default:
				return JsonValue.Create( element.Clone() );
		}
	}

	/// <summary>Read a document from disk. Returns null when the file is missing or unreadable.</summary>
	public static PrismGraph ReadFile( string absolutePath, DiagnosticSink sink = 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}",
				null, e.ToString() );
			return null;
		}

		var graph = Read( text, sink );

		if ( graph is not null ) graph.AssetPath = absolutePath;

		return graph;
	}

	// ---------------------------------------------------------------- writing ----

	/// <summary>Serialize a document to its canonical text form.</summary>
	public static string Write( PrismGraph graph ) => WriteJson( graph )?.ToJsonString( PrismJson.Options ) ?? "{}";

	/// <summary>
	/// Serialize a document to JSON. Key order is fixed by the schema, arrays keep document order and
	/// every float is written exactly, so an unchanged document always produces identical text.
	/// </summary>
	public static JsonObject WriteJson( PrismGraph graph )
	{
		var root = new JsonObject();

		if ( graph is null ) return root;

		root[KeySchema] = PrismConstants.DocumentSchemaVersion;
		root[KeyId] = graph.DocumentId;
		root[KeyKind] = graph.Kind;

		var meta = WriteMeta( graph.Meta );
		if ( meta.Count > 0 ) root["meta"] = meta;

		root["settings"] = WriteSettings( graph.Settings ?? new GraphSettings() );
		root["preview"] = WritePreview( graph.Preview ?? new PreviewState() );

		if ( graph.Parameters.Count > 0 )
		{
			var array = new JsonArray();

			foreach ( var parameter in graph.Parameters )
			{
				array.Add( WriteParameter( parameter ) );
			}

			root["parameters"] = array;
		}

		if ( graph.Keywords.Count > 0 )
		{
			var array = new JsonArray();

			foreach ( var keyword in graph.Keywords )
			{
				array.Add( WriteKeyword( keyword ) );
			}

			root["keywords"] = array;
		}

		var nodes = new JsonArray();

		foreach ( var node in graph.Nodes )
		{
			nodes.Add( WriteNode( graph, node ) );
		}

		root["nodes"] = nodes;

		var edges = new JsonArray();

		foreach ( var edge in graph.Edges )
		{
			edges.Add( WriteEdge( edge ) );
		}

		foreach ( var broken in graph.BrokenEdges )
		{
			if ( broken?.Edge is null ) continue;

			edges.Add( WriteEdge( broken.Edge ) );
		}

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

		if ( graph.Groups.Count > 0 )
		{
			var array = new JsonArray();

			foreach ( var group in graph.Groups )
			{
				array.Add( WriteGroup( group ) );
			}

			root["groups"] = array;
		}

		if ( graph.Notes.Count > 0 )
		{
			var array = new JsonArray();

			foreach ( var note in graph.Notes )
			{
				array.Add( WriteNote( note ) );
			}

			root["notes"] = array;
		}

		if ( graph.View is not null )
		{
			root["view"] = new JsonObject
			{
				["center"] = ValueCodec.Vector( graph.View.Center.x, graph.View.Center.y ),
				["zoom"] = ValueCodec.Number( graph.View.Zoom )
			};
		}

		MergeExtra( root, graph.X );

		return root;
	}

	/// <summary>
	/// Write a document to disk atomically, with an integrity check.
	/// <para>
	/// The text is re-parsed before anything on disk is touched; if what we just produced does not
	/// read back, the original file is left exactly as it was and the failure is reported. Then the
	/// text goes to a sibling temp file and is moved over the original, so a crash mid-write cannot
	/// leave a half-written document.
	/// </para>
	/// </summary>
	public static bool Save( PrismGraph graph, string absolutePath, DiagnosticSink sink = null )
	{
		if ( graph is null || string.IsNullOrWhiteSpace( absolutePath ) ) return false;

		graph.Meta ??= new GraphMeta();
		graph.Meta.Created ??= DateTimeOffset.UtcNow;
		graph.Meta.Modified = DateTimeOffset.UtcNow;
		graph.Meta.EditorVersion = PrismConstants.EditorVersion;

		var text = PrismLog.Guard( "Serialize document", () => Write( graph ) );

		if ( string.IsNullOrEmpty( text ) )
		{
			sink?.Error( DiagnosticCode.RoundTripFailed, "The document could not be serialized" );
			return false;
		}

		var check = new DiagnosticSink();

		if ( !TryRead( text, out var reparsed, check ) || reparsed is null || check.HasErrors )
		{
			sink?.Error( DiagnosticCode.RoundTripFailed,
				"The document did not survive its own integrity check and was NOT written",
				null, string.Join( Environment.NewLine, check.All.Select( x => x.ToString() ) ) );
			return false;
		}

		if ( reparsed.Nodes.Count != graph.Nodes.Count )
		{
			sink?.Error( DiagnosticCode.RoundTripFailed,
				$"Integrity check lost nodes ({graph.Nodes.Count} written, {reparsed.Nodes.Count} read back); " +
				"the file was NOT written" );
			return false;
		}

		// Counting nodes proves the document survived; it does not prove its contents did. Writing what we
		// just read has to reproduce the same bytes, and when it does not, something inside a node — a
		// literal, a property, a flag — was written correctly and dropped on the way back in. That is the
		// shape of every silent data loss this format can have: the file on disk is fine and the next open
		// is not.
		//
		// This warns rather than refusing. A save the user cannot perform is worse than a save that loses
		// one literal, and the only honest thing to do with a difference we did not anticipate is to write
		// the file and say exactly what drifted.
		var drift = PrismLog.Guard<string>( "Verifying the document round-trip", () => Diff( text, Write( reparsed ) ) );

		if ( !string.IsNullOrEmpty( drift ) )
		{
			sink?.Warn( DiagnosticCode.RoundTripFailed,
				"The document was saved, but part of it does not read back the way it was written",
				null, $"Reopening this file will not reproduce it exactly. {drift}" );
		}

		var temp = absolutePath + ".tmp";

		try
		{
			var directory = Path.GetDirectoryName( absolutePath );

			if ( !string.IsNullOrEmpty( directory ) ) Directory.CreateDirectory( directory );

			File.WriteAllText( temp, text, new UTF8Encoding( false ) );

			if ( File.Exists( absolutePath ) ) File.Replace( temp, absolutePath, null, true );
			else File.Move( temp, absolutePath );
		}
		catch ( Exception e )
		{
			sink?.Error( DiagnosticCode.RoundTripFailed, $"Could not write '{absolutePath}': {e.Message}",
				null, e.ToString() );

			PrismLog.Guard( "Delete temp file", () =>
			{
				if ( File.Exists( temp ) ) File.Delete( temp );
			} );

			return false;
		}

		graph.AssetPath = absolutePath;
		graph.IsDirty = false;

		return true;
	}

	// ---------------------------------------------------------------- fragments ----

	/// <summary>
	/// Serialize a subset of a document: the given nodes, the edges strictly between them, and any
	/// blackboard parameter one of them references. This is the clipboard and undo-fragment form.
	/// </summary>
	public static string WriteNodes( PrismGraph graph, IEnumerable<PrismNode> nodes )
	{
		var root = new JsonObject
		{
			[KeySchema] = PrismConstants.DocumentSchemaVersion,
			[KeyKind] = "fragment"
		};

		if ( graph is null || nodes is null ) return root.ToJsonString( PrismJson.Options );

		var selected = nodes.Where( x => x is not null ).ToArray();
		var ids = new HashSet<NodeId>( selected.Select( x => x.Id ) );

		var nodeArray = new JsonArray();

		foreach ( var node in selected )
		{
			nodeArray.Add( WriteNode( graph, node ) );
		}

		root["nodes"] = nodeArray;

		var edgeArray = new JsonArray();

		foreach ( var edge in graph.Edges )
		{
			if ( !ids.Contains( edge.FromNode ) || !ids.Contains( edge.ToNode ) ) continue;

			edgeArray.Add( WriteEdge( edge ) );
		}

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

		// Carry any parameter whose id appears in a copied node's properties, so pasting into another
		// document does not produce dangling parameter references.
		var referenced = new JsonArray();
		var payload = nodeArray.ToJsonString();

		foreach ( var parameter in graph.Parameters )
		{
			if ( !payload.Contains( $"\"{parameter.Id.Value}\"", StringComparison.Ordinal ) ) continue;

			referenced.Add( WriteParameter( parameter ) );
		}

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

		return root.ToJsonString( PrismJson.Options );
	}

	/// <summary>
	/// Read a fragment into an existing document. Node ids are re-minted when
	/// <paramref name="remapIds"/> is set (paste and duplicate) and preserved otherwise (undo restore
	/// of a deleted selection). Edges inside the fragment are rewired to the new ids.
	/// </summary>
	public static IReadOnlyList<PrismNode> ReadNodes( PrismGraph graph, string json, bool remapIds,
		DiagnosticSink sink = null )
	{
		var added = new List<PrismNode>();

		if ( graph is null || string.IsNullOrWhiteSpace( json ) ) return added;

		JsonObject root;

		try
		{
			root = ParseObject( json );
		}
		catch ( Exception e )
		{
			sink?.Error( DiagnosticCode.SectionReadFailed, $"The fragment is not valid JSON: {e.Message}" );
			return added;
		}

		if ( root is null ) return added;

		// A fragment carries a schema stamp — WriteNodes writes one on every payload — so it has to be
		// upgraded like any other document before it is read. ReadNode runs the per-node migrations, but
		// a document-level one (a section rename, a reshaped edge) would otherwise be skipped for
		// exactly the clipboard payload that was copied before the upgrade. Dormant at schema v1, and
		// the sort of thing that only shows up once, silently, on the day v2 ships.
		root = PrismLog.Guard( "Upgrading the fragment schema", () => SchemaMigrations.Upgrade( root, sink ) ) ?? root;

		var idMap = new Dictionary<NodeId, NodeId>();
		var paramMap = new Dictionary<string, string>( StringComparer.Ordinal );

		if ( root["parameters"] is JsonArray parameters )
		{
			foreach ( var element in parameters )
			{
				if ( element is not JsonObject parameterJson ) continue;

				var parameter = ReadParameter( parameterJson );

				if ( parameter is null ) continue;

				var existing = graph.FindParameter( parameter.Id );

				if ( existing is not null ) continue;

				var byName = graph.FindParameterByName( parameter.Name );

				if ( byName is not null )
				{
					paramMap[parameter.Id.Value] = byName.Id.Value;
					continue;
				}

				var oldId = parameter.Id.Value;
				graph.AddParameter( parameter );
				if ( parameter.Id.Value != oldId ) paramMap[oldId] = parameter.Id.Value;
			}
		}

		if ( root["nodes"] is JsonArray nodes )
		{
			foreach ( var element in nodes )
			{
				if ( element is not JsonObject nodeJson )
				{
					sink?.Warn( DiagnosticCode.NodeReadFailed, "A fragment entry was not an object and was skipped" );
					continue;
				}

				if ( paramMap.Count > 0 ) RemapParameterIds( nodeJson, paramMap );

				var original = NodeId.Parse( ValueCodec.StringOf( nodeJson[KeyId] ) );
				var node = ReadNode( graph, nodeJson, sink, remapIds );

				if ( node is null ) continue;

				if ( original.IsValid ) idMap[original] = node.Id;

				added.Add( node );
			}
		}

		foreach ( var node in added )
		{
			if ( node is UnknownNode unknown ) unknown.Rebuild();
		}

		if ( root["edges"] is JsonArray edges )
		{
			foreach ( var parsed in ParseEdges( edges, sink ) )
			{
				var edge = parsed.Edge;

				if ( idMap.TryGetValue( edge.FromNode, out var from ) ) edge = edge with { FromNode = from };
				if ( idMap.TryGetValue( edge.ToNode, out var to ) ) edge = edge with { ToNode = to };

				edge = edge with { Id = graph.NewEdgeId() };

				if ( graph.FindOutput( edge.From ) is null || graph.FindInput( edge.To ) is null ) continue;

				graph.AddEdge( edge );
			}
		}

		return added;
	}

	/// <summary>
	/// Reconcile a live document against a snapshot, in place.
	/// <para>
	/// This is what makes undo non-destructive to the UI: a node whose id and type survive keeps its
	/// object identity, so the card, its selection state and its widget tree are reused rather than
	/// rebuilt. Only nodes that genuinely appeared or disappeared are added or removed.
	/// </para>
	/// </summary>
	public static bool Restore( PrismGraph graph, string json, DiagnosticSink sink = null )
	{
		if ( graph is null || string.IsNullOrWhiteSpace( json ) ) return false;

		JsonObject root;

		try
		{
			root = ParseObject( json );
		}
		catch ( Exception e )
		{
			sink?.Error( DiagnosticCode.SectionReadFailed, $"The snapshot is not valid JSON: {e.Message}" );
			return false;
		}

		if ( root is null ) return false;

		root = SchemaMigrations.Upgrade( root, sink ) ?? root;

		using ( graph.SuspendEvents() )
		{
			// Identity is part of the snapshot. A restore that left it alone would be a restore that
			// produced a different document from the one a fresh read of the same text produces, and
			// undo is only trustworthy because those two are the same operation.
			var documentId = ValueCodec.StringOf( root[KeyId] );

			if ( !string.IsNullOrEmpty( documentId ) ) graph.DocumentId = documentId;

			// Kind is unconditional, because an absent one means "shader" rather than "keep what you are".
			graph.IsSubgraph = string.Equals( ValueCodec.StringOf( root[KeyKind] ),
				PrismConstants.DocumentKindSubgraph, StringComparison.OrdinalIgnoreCase );

			var wanted = new Dictionary<NodeId, JsonObject>();
			var order = new List<NodeId>();

			if ( root["nodes"] is JsonArray nodeArray )
			{
				foreach ( var element in nodeArray )
				{
					if ( element is not JsonObject nodeJson ) continue;

					var id = NodeId.Parse( ValueCodec.StringOf( nodeJson[KeyId] ) );

					if ( !id.IsValid || wanted.ContainsKey( id ) ) continue;

					wanted[id] = nodeJson;
					order.Add( id );
				}
			}

			foreach ( var node in graph.Nodes.ToArray() )
			{
				if ( wanted.ContainsKey( node.Id ) && SameType( node, wanted[node.Id] ) ) continue;

				graph.RemoveNode( node );
			}

			foreach ( var edge in graph.Edges.ToArray() )
			{
				graph.RemoveEdge( edge.Id );
			}

			foreach ( var ghost in graph.BrokenEdges.ToArray() )
			{
				graph.RemoveBrokenEdge( ghost.Id );
			}

			foreach ( var id in order )
			{
				var live = graph.FindNode( id );

				if ( live is not null )
				{
					ApplyNode( graph, live, wanted[id], sink );
					continue;
				}

				ReadNode( graph, wanted[id], sink, false );
			}

			// Restore document order exactly as the snapshot had it.
			ReorderNodes( graph, order );

			var edges = ParseEdges( root["edges"] as JsonArray, sink );

			foreach ( var node in graph.Nodes )
			{
				if ( node is UnknownNode unknown ) unknown.RebuildFromEdges( edges.Select( x => x.Edge ) );
			}

			ResolveEdges( graph, edges, sink );

			ReplaceParameters( graph, root["parameters"] as JsonArray, sink );
			ReplaceKeywords( graph, root["keywords"] as JsonArray, sink );
			ReplaceGroups( graph, root["groups"] as JsonArray, sink );
			ReplaceNotes( graph, root["notes"] as JsonArray, sink );

			ReadMeta( graph, root["meta"] as JsonObject );
			ReadSettings( graph, root["settings"] as JsonObject, sink );
			ReadPreview( graph, root["preview"] as JsonObject );
			ReadView( graph, root["view"] as JsonObject );

			graph.X = ExtraBag( root, s_documentKeys );
		}

		return true;
	}

	// ---------------------------------------------------------------- DTO bridge ----

	/// <summary>
	/// A typed view of a document, for callers that prefer the DTO records to raw JSON. Produced from
	/// the canonical text form, so it can never disagree with what a save would write.
	/// </summary>
	public static PrismDocument ToDocument( PrismGraph graph ) =>
		PrismLog.Guard<PrismDocument>( "Build PrismDocument",
			() => JsonSerializer.Deserialize<PrismDocument>( Write( graph ), PrismJson.Options ) );

	/// <summary>Build a document from the typed DTO view.</summary>
	public static PrismGraph FromDocument( PrismDocument document, DiagnosticSink sink = null )
	{
		if ( document is null ) return null;

		var json = PrismLog.Guard<string>( "Serialize PrismDocument",
			() => JsonSerializer.Serialize( document, PrismJson.Options ) );

		return string.IsNullOrEmpty( json ) ? null : Read( json, sink );
	}

	// ---------------------------------------------------------------- sections ----

	/// <summary>
	/// Apply the <c>meta</c> section. Every field is assigned even when the section is absent, because
	/// this also runs against a live document during an undo restore: a section the snapshot does not
	/// carry means "there is none", not "keep the one you have".
	/// </summary>
	static void ReadMeta( PrismGraph graph, JsonObject json )
	{
		graph.Meta ??= new GraphMeta();

		json ??= new JsonObject();

		// An empty string is normalised to null throughout, because the writer omits empty values: without
		// this, `"title": ""` would survive one write and vanish on the next, and a document that changes
		// on every save is a document that shows up dirty in source control forever.
		graph.Meta.Title = Text( json["title"] );
		graph.Meta.Description = Text( json["description"] );
		graph.Meta.Category = Text( json["category"] );
		graph.Meta.Icon = Text( json["icon"] );
		graph.Meta.Author = Text( json["author"] );
		graph.Meta.Created = ParseTime( json["created"] );
		graph.Meta.Modified = ParseTime( json["modified"] );
		graph.Meta.EditorVersion = Text( json["editorVersion"] ) ?? PrismConstants.EditorVersion;
	}

	static JsonObject WriteMeta( GraphMeta meta )
	{
		var json = new JsonObject();

		if ( meta is null ) return json;

		if ( !string.IsNullOrEmpty( meta.Title ) ) json["title"] = meta.Title;
		if ( !string.IsNullOrEmpty( meta.Description ) ) json["description"] = meta.Description;
		if ( !string.IsNullOrEmpty( meta.Category ) ) json["category"] = meta.Category;
		if ( !string.IsNullOrEmpty( meta.Icon ) ) json["icon"] = meta.Icon;
		if ( !string.IsNullOrEmpty( meta.Author ) ) json["author"] = meta.Author;
		if ( meta.Created is { } created ) json["created"] = FormatTime( created );
		if ( meta.Modified is { } modified ) json["modified"] = FormatTime( modified );
		if ( !string.IsNullOrEmpty( meta.EditorVersion ) ) json["editorVersion"] = meta.EditorVersion;

		return json;
	}

	/// <inheritdoc cref="ReadMeta"/>
	static void ReadSettings( PrismGraph graph, JsonObject json, DiagnosticSink sink )
	{
		graph.Settings ??= new GraphSettings();

		json ??= new JsonObject();

		var settings = graph.Settings;

		settings.Domain = ReadEnum( json["domain"], ShaderDomain.Surface );
		settings.ShadingModel = ReadEnum( json["shadingModel"], ShadingModel.Lit );
		settings.BlendMode = ReadEnum( json["blendMode"], SurfaceBlendMode.Opaque );
		settings.CullMode = ReadEnum( json["cullMode"], CullMode.Back );
		settings.HlslDialect = ReadEnum( json["hlslDialect"], HlslDialect.SboxSlang );
		settings.Modes = ReadStrings( json["modes"] ) ?? new List<string>( GraphSettings.DefaultModes );
		settings.Targets = ReadStrings( json["targets"] ) ?? new List<string> { PrismConstants.BackendHlsl };
		settings.Uv2 = ReadBool( json["uv2"] );
		settings.RenderBackfaces = ReadBool( json["renderBackfaces"] );
		settings.DebugSymbols = ReadBool( json["debugSymbols"] );
		settings.StrictTypes = ReadBool( json["strictTypes"] );
		settings.X = ExtraBag( json, s_settingsKeys );

		settings.Normalize( sink );
	}

	static JsonObject WriteSettings( GraphSettings settings )
	{
		var json = new JsonObject
		{
			["domain"] = settings.Domain.ToString(),
			["shadingModel"] = settings.ShadingModel.ToString(),
			["blendMode"] = settings.BlendMode.ToString(),
			["cullMode"] = settings.CullMode.ToString(),
			["modes"] = WriteStrings( settings.Modes ),
			["targets"] = WriteStrings( settings.Targets ),
			["hlslDialect"] = settings.HlslDialect.ToString(),
			["uv2"] = settings.Uv2,
			["renderBackfaces"] = settings.RenderBackfaces,
			["debugSymbols"] = settings.DebugSymbols,
			["strictTypes"] = settings.StrictTypes
		};

		MergeExtra( json, settings.X );

		return json;
	}

	/// <inheritdoc cref="ReadMeta"/>
	static void ReadPreview( PrismGraph graph, JsonObject json )
	{
		graph.Preview ??= new PreviewState();

		json ??= new JsonObject();

		var preview = graph.Preview;
		var defaults = new PreviewState();

		preview.Mesh = Text( json["mesh"] ) ?? defaults.Mesh;
		preview.Model = Text( json["model"] );
		preview.Envmap = Text( json["envmap"] );
		preview.ShowGround = ReadBool( json["showGround"] );
		preview.ShowSkybox = ReadBool( json["showSkybox"], true );
		preview.Channel = Text( json["channel"] );

		preview.Tint = ValueCodec.TryParseColor( ValueCodec.StringOf( json["tint"] ), out var tint )
			? tint
			: defaults.Tint;

		preview.Background = ValueCodec.TryParseColor( ValueCodec.StringOf( json["background"] ), out var background )
			? background
			: defaults.Background;

		var camera = json["camera"] as JsonObject ?? new JsonObject();

		preview.Camera ??= new PreviewCamera();
		preview.Camera.Yaw = Geometry( ValueCodec.NumberOf( camera["yaw"], defaults.Camera.Yaw ), defaults.Camera.Yaw );
		preview.Camera.Pitch = Geometry( ValueCodec.NumberOf( camera["pitch"], defaults.Camera.Pitch ),
			defaults.Camera.Pitch );
		preview.Camera.Distance = Geometry( ValueCodec.NumberOf( camera["distance"], defaults.Camera.Distance ),
			defaults.Camera.Distance );
	}

	static JsonObject WritePreview( PreviewState preview )
	{
		var camera = preview.Camera ?? new PreviewCamera();

		var json = new JsonObject
		{
			["mesh"] = preview.Mesh,
			["model"] = preview.Model,
			["envmap"] = preview.Envmap,
			["showGround"] = preview.ShowGround,
			["showSkybox"] = preview.ShowSkybox,
			["tint"] = ValueCodec.FormatColor( preview.Tint ),
			["background"] = ValueCodec.FormatColor( preview.Background ),
			["camera"] = new JsonObject
			{
				["yaw"] = ValueCodec.Number( camera.Yaw ),
				["pitch"] = ValueCodec.Number( camera.Pitch ),
				["distance"] = ValueCodec.Number( camera.Distance )
			}
		};

		if ( !string.IsNullOrEmpty( preview.Channel ) ) json["channel"] = preview.Channel;

		return json;
	}

	static void ReadParameters( PrismGraph graph, JsonArray array, DiagnosticSink sink )
	{
		if ( array is null ) return;

		foreach ( var element in array )
		{
			if ( element is not JsonObject json )
			{
				sink?.Warn( DiagnosticCode.SectionReadFailed, "A parameter entry was not an object and was skipped" );
				continue;
			}

			var parameter = PrismLog.Guard<Parameter>( "Read parameter", () => ReadParameter( json ) );

			if ( parameter is null )
			{
				sink?.Warn( DiagnosticCode.SectionReadFailed, "A parameter could not be read and was skipped" );
				continue;
			}

			graph.AddParameter( parameter );
		}
	}

	static Parameter ReadParameter( JsonObject json )
	{
		if ( json is null ) return null;

		var typeText = ValueCodec.StringOf( json["type"] ) ?? "float";

		if ( !ShaderType.TryParse( typeText, out var type ) ) type = ShaderType.Float;

		var parameter = new Parameter
		{
			Id = ParamId.Parse( ValueCodec.StringOf( json[KeyId] ) ),
			Name = ValueCodec.StringOf( json["name"] ) ?? "Parameter",
			Type = type,
			AttributeName = ValueCodec.StringOf( json["attribute"] ),
			X = ExtraBag( json, s_parameterKeys )
		};

		if ( !parameter.Id.IsValid ) parameter.Id = ParamId.New();

		parameter.Default = json["default"] is { } defaultNode
			? ValueCodec.Read( type, defaultNode )
			: ValueCodec.Default( type );

		if ( json["ui"] is JsonObject ui )
		{
			parameter.Ui = new ParameterUi
			{
				Control = ReadEnum( ui["control"], UiControl.Default ),
				Min = ReadNullableFloat( ui["min"] ),
				Max = ReadNullableFloat( ui["max"] ),
				Step = ReadNullableFloat( ui["step"] ),
				Group = ValueCodec.StringOf( ui["group"] ),
				Order = (int)ValueCodec.NumberOf( ui["order"] ),
				Tooltip = ValueCodec.StringOf( ui["tooltip"] ),
				Options = ReadStrings( ui["options"] )
			};
		}

		return parameter;
	}

	static JsonObject WriteParameter( Parameter parameter )
	{
		var json = new JsonObject
		{
			[KeyId] = parameter.Id.Value,
			["name"] = parameter.Name,
			["type"] = parameter.Type.ToString()
		};

		var value = ValueCodec.Write( parameter.Type, parameter.Default );

		if ( value is not null ) json["default"] = value;
		if ( !string.IsNullOrEmpty( parameter.AttributeName ) ) json["attribute"] = parameter.AttributeName;

		var ui = parameter.Ui;

		if ( ui is not null && !ui.IsEmpty )
		{
			var uiJson = new JsonObject();

			if ( ui.Control != UiControl.Default ) uiJson["control"] = ui.Control.ToString();
			if ( ui.Min is { } min ) uiJson["min"] = ValueCodec.Number( min );
			if ( ui.Max is { } max ) uiJson["max"] = ValueCodec.Number( max );
			if ( ui.Step is { } step ) uiJson["step"] = ValueCodec.Number( step );
			if ( !string.IsNullOrEmpty( ui.Group ) ) uiJson["group"] = ui.Group;
			if ( ui.Order != 0 ) uiJson["order"] = ui.Order;
			if ( !string.IsNullOrEmpty( ui.Tooltip ) ) uiJson["tooltip"] = ui.Tooltip;
			if ( ui.Options is { Count: > 0 } ) uiJson["options"] = WriteStrings( ui.Options );

			json["ui"] = uiJson;
		}

		MergeExtra( json, parameter.X );

		return json;
	}

	static void ReadKeywords( PrismGraph graph, JsonArray array, DiagnosticSink sink )
	{
		if ( array is null ) return;

		foreach ( var element in array )
		{
			if ( element is not JsonObject json )
			{
				sink?.Warn( DiagnosticCode.SectionReadFailed, "A keyword entry was not an object and was skipped" );
				continue;
			}

			var keyword = new Keyword
			{
				Id = ParamId.Parse( ValueCodec.StringOf( json[KeyId] ) ),
				Name = ValueCodec.StringOf( json["name"] ) ?? "F_KEYWORD",
				Kind = ReadEnum( json["kind"], ComboKind.Feature ),
				Values = ReadStrings( json["values"] ) ?? new List<string> { "Off", "On" },
				Default = (int)ValueCodec.NumberOf( json["default"] ),
				Group = ValueCodec.StringOf( json["group"] )
			};

			if ( !keyword.Id.IsValid ) keyword.Id = ParamId.New();

			graph.AddKeyword( keyword );
		}
	}

	static JsonObject WriteKeyword( Keyword keyword )
	{
		var json = new JsonObject
		{
			[KeyId] = keyword.Id.Value,
			["name"] = keyword.Name,
			["kind"] = keyword.Kind.ToString(),
			["values"] = WriteStrings( keyword.Values )
		};

		if ( keyword.Default != 0 ) json["default"] = keyword.Default;
		if ( !string.IsNullOrEmpty( keyword.Group ) ) json["group"] = keyword.Group;

		return json;
	}

	static void ReadNodes( PrismGraph graph, JsonArray array, DiagnosticSink sink )
	{
		if ( array is null ) return;

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

			ReadNode( graph, json, sink, false );
		}
	}

	static PrismNode ReadNode( PrismGraph graph, JsonObject json, DiagnosticSink sink, bool remapId )
	{
		var id = NodeId.Parse( ValueCodec.StringOf( json[KeyId] ) );
		var rawType = ValueCodec.StringOf( json["type"] ) ?? string.Empty;
		var version = ReadVersion( json["v"] );

		var resolvedType = NodeMigrations.ResolveTypeId( rawType );
		var registered = NodeRegistry.Find( resolvedType );

		PrismNode node = null;
		var applied = json;

		if ( registered is not null )
		{
			applied = NodeMigrations.Upgrade( json.DeepClone() as JsonObject, registered.Id, version,
				registered.Descriptor.Version, sink ) ?? json;

			node = registered.Create();

			if ( node is null )
			{
				sink?.Error( DiagnosticCode.NodeReadFailed,
					$"Node type '{resolvedType}' could not be constructed; its data was preserved as a placeholder",
					GraphRef.ForNode( id ) );
			}
		}

		if ( node is null )
		{
			node = new UnknownNode( rawType, json, version )
			{
				IsMissingPlugin = registered is null,
				Reason = registered is null
					? "The node type is not registered. Install the addon that provides it and reopen the graph."
					: "The node type is registered but could not be constructed."
			};

			if ( registered is null && !string.IsNullOrEmpty( rawType ) )
			{
				sink?.Warn( DiagnosticCode.UnknownNodeType,
					$"Unknown node type '{rawType}'; it will be preserved exactly as written",
					GraphRef.ForNode( id ) );
			}
		}

		if ( id.IsValid && !remapId )
		{
			if ( graph.FindNode( id ) is not null )
			{
				sink?.Warn( DiagnosticCode.DuplicateNodeId,
					$"Two nodes share the id '{id}'; the second was given a new one", GraphRef.ForNode( id ) );
			}
			else
			{
				node.Id = id;
			}
		}

		graph.AddNode( node );
		ApplyNode( graph, node, applied, sink );

		return node;
	}

	static void ApplyNode( PrismGraph graph, PrismNode node, JsonObject json, DiagnosticSink sink )
	{
		if ( node is null || json is null ) return;

		node.Position = ReadVector2( json["pos"] );
		node.Flags = ReadFlags( json["flags"] as JsonObject );

		if ( json["size"] is JsonArray ) graph.SetNodeSize( node.Id, ReadVector2( json["size"] ) );
		else graph.SetNodeSize( node.Id, null );

		if ( node is UnknownNode unknown )
		{
			unknown.Raw = json.DeepClone() as JsonObject;
			unknown.TypeVersion = ReadVersion( json["v"], unknown.TypeVersion );
			return;
		}

		graph.SetNodeExtra( node.Id, json["x"] as JsonObject );

		// The node object may be a live one being reconciled against a snapshot rather than a fresh one,
		// so anything the document does not mention has to go back to the default first.
		NodeProperties.ResetProperties( node );
		NodeProperties.Read( node, json["props"] as JsonObject, sink );
		NodeProperties.RebuildPorts( node );

		// A document only records a literal that differs from the type's default, so anything the
		// document does not mention has to go back to the default before the recorded ones are applied.
		NodeProperties.ResetInline( node );
		NodeProperties.SyncInlineFromProperties( node );

		if ( json["inline"] is JsonObject inline )
		{
			foreach ( var pair in inline )
			{
				var portId = PortId.Parse( pair.Key );

				if ( !NodeMigrations.ResolvePort( node, portId, PortDirection.Input, out var resolved ) )
				{
					sink?.Warn( DiagnosticCode.NodeReadFailed,
						$"Inline value for '{pair.Key}' has no matching port and was dropped",
						GraphRef.ForNode( node.Id ) );
					continue;
				}

				var port = node.FindInput( resolved );

				if ( port is null ) continue;

				var value = port.Def.IsGeneric
					? ValueCodec.ReadUntyped( pair.Value )
					: ValueCodec.Read( port.Def.FixedType, pair.Value );

				NodeProperties.SetInline( node, port, value );
			}
		}
	}

	static JsonObject WriteNode( PrismGraph graph, PrismNode node )
	{
		if ( node is UnknownNode unknown )
		{
			var raw = unknown.ToJson();

			raw[KeyId] = node.Id.Value;
			raw["pos"] = ValueCodec.Vector( node.Position.x, node.Position.y );

			// Position and size both live on the graph's side tables rather than on the node, so both
			// have to be re-stamped onto the verbatim bag. Writing one and not the other lost a manual
			// resize of a missing-plugin node on every save — and, because undo round-trips through this
			// same writer, on every undo too.
			if ( graph?.GetNodeSize( node.Id ) is { } unknownSize )
			{
				raw["size"] = ValueCodec.Vector( unknownSize.x, unknownSize.y );
			}
			else
			{
				raw.Remove( "size" );
			}

			return raw;
		}

		var descriptor = node.Descriptor;

		var json = new JsonObject
		{
			[KeyId] = node.Id.Value,
			["type"] = descriptor?.Id ?? node.GetType().FullName,
			["v"] = descriptor?.Version ?? 1,
			["pos"] = ValueCodec.Vector( node.Position.x, node.Position.y )
		};

		if ( graph?.GetNodeSize( node.Id ) is { } size )
		{
			json["size"] = ValueCodec.Vector( size.x, size.y );
		}

		json["props"] = NodeProperties.Write( node );

		var inline = WriteInline( node );

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

		var flags = WriteFlags( node.Flags );

		if ( flags.Count > 0 ) json["flags"] = flags;

		MergeExtra( json, graph?.GetNodeExtra( node.Id ) );

		return json;
	}

	static JsonObject WriteInline( PrismNode node )
	{
		var json = new JsonObject();
		var prototype = NodeProperties.Prototype( node.GetType() );

		foreach ( var port in node.Inputs )
		{
			var value = NodeProperties.GetInline( node, port );

			if ( value is null ) continue;

			// Only write a literal that differs from what a fresh node of this type would have, so
			// documents stay small and a default change in a later version is picked up.
			var bound = port.Def?.InlineValueProperty;

			if ( prototype is not null && !string.IsNullOrEmpty( bound ) )
			{
				var original = NodeProperties.Get( prototype, bound );

				if ( ValueCodec.Equal( original, value ) ) continue;
			}

			var encoded = port.Def.IsGeneric
				? ValueCodec.Write( value )
				: ValueCodec.Write( port.Def.FixedType, value );

			if ( encoded is null ) continue;

			json[port.Id.Value] = encoded;
		}

		return json;
	}

	static NodeFlags ReadFlags( JsonObject json )
	{
		if ( json is null ) return NodeFlags.None;

		var flags = NodeFlags.None;

		if ( ReadBool( json["preview"] ) ) flags |= NodeFlags.Preview;
		if ( ReadBool( json["collapsed"] ) ) flags |= NodeFlags.Collapsed;
		if ( ReadBool( json["disabled"] ) ) flags |= NodeFlags.Disabled;
		if ( ReadBool( json["pinned"] ) ) flags |= NodeFlags.Pinned;

		return flags;
	}

	static JsonObject WriteFlags( NodeFlags flags )
	{
		var json = new JsonObject();

		if ( ( flags & NodeFlags.Preview ) != 0 ) json["preview"] = true;
		if ( ( flags & NodeFlags.Collapsed ) != 0 ) json["collapsed"] = true;
		if ( ( flags & NodeFlags.Disabled ) != 0 ) json["disabled"] = true;
		if ( ( flags & NodeFlags.Pinned ) != 0 ) json["pinned"] = true;

		return json;
	}

	readonly record struct ParsedEdge( Edge Edge, JsonObject Source );

	static List<ParsedEdge> ParseEdges( JsonArray array, DiagnosticSink sink )
	{
		var result = new List<ParsedEdge>();

		if ( array is null ) return result;

		foreach ( var element in array )
		{
			if ( element is not JsonObject json )
			{
				sink?.Warn( DiagnosticCode.EdgeReadFailed, "A connection entry was not an object and was skipped" );
				continue;
			}

			var from = json["from"] as JsonObject;
			var to = json["to"] as JsonObject;

			if ( from is null || to is null )
			{
				sink?.Warn( DiagnosticCode.EdgeReadFailed, "A connection is missing an endpoint and was skipped" );
				continue;
			}

			var edge = new Edge(
				EdgeId.Parse( ValueCodec.StringOf( json[KeyId] ) ),
				NodeId.Parse( ValueCodec.StringOf( from["node"] ) ),
				PortId.Parse( ValueCodec.StringOf( from["port"] ) ),
				NodeId.Parse( ValueCodec.StringOf( to["node"] ) ),
				PortId.Parse( ValueCodec.StringOf( to["port"] ) ),
				ReadVia( json["via"] as JsonArray ) )
			{
				Fill = ReadNullableFloat( json["fill"] )
			};

			if ( !edge.Id.IsValid ) edge = edge with { Id = EdgeId.New() };

			if ( !edge.IsValid )
			{
				sink?.Warn( DiagnosticCode.EdgeReadFailed, "A connection had an empty endpoint and was skipped",
					GraphRef.ForEdge( edge.Id ) );
				continue;
			}

			result.Add( new ParsedEdge( edge, json ) );
		}

		return result;
	}

	static void ResolveEdges( PrismGraph graph, List<ParsedEdge> edges, DiagnosticSink sink )
	{
		foreach ( var parsed in edges )
		{
			var edge = parsed.Edge;

			var fromNode = graph.FindNode( edge.FromNode );
			var toNode = graph.FindNode( edge.ToNode );

			if ( fromNode is null )
			{
				Break( graph, sink, edge, BrokenEdgeReason.MissingFromNode,
					$"No node with id '{edge.FromNode}' exists in this document." );
				continue;
			}

			if ( toNode is null )
			{
				Break( graph, sink, edge, BrokenEdgeReason.MissingToNode,
					$"No node with id '{edge.ToNode}' exists in this document." );
				continue;
			}

			if ( NodeMigrations.ResolvePort( fromNode, edge.FromPort, PortDirection.Output, out var fromPort ) )
			{
				edge = edge with { FromPort = fromPort };
			}
			else
			{
				Break( graph, sink, edge, BrokenEdgeReason.MissingFromPort,
					$"'{edge.FromPort}' is not an output of that node." );
				continue;
			}

			if ( NodeMigrations.ResolvePort( toNode, edge.ToPort, PortDirection.Input, out var toPort ) )
			{
				edge = edge with { ToPort = toPort };
			}
			else
			{
				Break( graph, sink, edge, BrokenEdgeReason.MissingToPort,
					$"'{edge.ToPort}' is not an input of that node." );
				continue;
			}

			graph.AddEdge( edge );
		}
	}

	static void Break( PrismGraph graph, DiagnosticSink sink, Edge edge, BrokenEdgeReason reason, string detail )
	{
		graph.AddBrokenEdge( edge, reason, detail );

		sink?.Warn( DiagnosticCode.DanglingEdge,
			$"Connection {edge} could not be resolved and is shown as a broken link",
			GraphRef.ForEdge( edge.Id ), detail );
	}

	static JsonObject WriteEdge( Edge edge )
	{
		var json = new JsonObject
		{
			[KeyId] = edge.Id.Value,
			["from"] = new JsonObject { ["node"] = edge.FromNode.Value, ["port"] = edge.FromPort.Value },
			["to"] = new JsonObject { ["node"] = edge.ToNode.Value, ["port"] = edge.ToPort.Value }
		};

		if ( edge.Via is { Length: > 0 } )
		{
			var via = new JsonArray();

			foreach ( var point in edge.Via )
			{
				via.Add( ValueCodec.Vector( point.x, point.y ) );
			}

			json["via"] = via;
		}

		if ( edge.Fill is { } fill ) json["fill"] = ValueCodec.Number( fill );

		return json;
	}

	static void ReadGroups( PrismGraph graph, JsonArray array, DiagnosticSink sink )
	{
		if ( array is null ) return;

		foreach ( var element in array )
		{
			if ( element is not JsonObject json )
			{
				sink?.Warn( DiagnosticCode.SectionReadFailed, "A group entry was not an object and was skipped" );
				continue;
			}

			var rect = ReadFloats( json["rect"] );

			var group = new GraphGroup
			{
				Id = ValueCodec.StringOf( json[KeyId] ) ?? Ids.NewShortId(),
				Title = ValueCodec.StringOf( json["title"] ) ?? "Group",
				Description = ValueCodec.StringOf( json["description"] ),
				Position = new Vector2( At( rect, 0 ), At( rect, 1 ) ),
				Size = new Vector2( At( rect, 2, 320f ), At( rect, 3, 200f ) ),
				Color = ValueCodec.StringOf( json["color"] ) ?? "Blue",
				Layer = (int)ValueCodec.NumberOf( json["layer"] )
			};

			graph.AddGroup( group );
		}
	}

	static JsonObject WriteGroup( GraphGroup group )
	{
		var json = new JsonObject
		{
			[KeyId] = group.Id,
			["title"] = group.Title
		};

		if ( !string.IsNullOrEmpty( group.Description ) ) json["description"] = group.Description;

		json["rect"] = ValueCodec.Vector( group.Position.x, group.Position.y, group.Size.x, group.Size.y );
		json["color"] = group.Color;

		if ( group.Layer != 0 ) json["layer"] = group.Layer;

		return json;
	}

	static void ReadNotes( PrismGraph graph, JsonArray array, DiagnosticSink sink )
	{
		if ( array is null ) return;

		foreach ( var element in array )
		{
			if ( element is not JsonObject json )
			{
				sink?.Warn( DiagnosticCode.SectionReadFailed, "A note entry was not an object and was skipped" );
				continue;
			}

			var note = new StickyNote
			{
				Id = ValueCodec.StringOf( json[KeyId] ) ?? Ids.NewShortId(),
				Position = ReadVector2( json["pos"] ),
				Size = json["size"] is null ? StickyNote.DefaultSize : ReadVector2( json["size"] ),
				Color = ValueCodec.StringOf( json["color"] ) ?? "Yellow",
				Text = ValueCodec.StringOf( json["text"] ) ?? string.Empty
			};

			graph.AddNote( note );
		}
	}

	static JsonObject WriteNote( StickyNote note ) => new()
	{
		[KeyId] = note.Id,
		["pos"] = ValueCodec.Vector( note.Position.x, note.Position.y ),
		["size"] = ValueCodec.Vector( note.Size.x, note.Size.y ),
		["color"] = note.Color,
		["text"] = note.Text
	};

	/// <inheritdoc cref="ReadMeta"/>
	static void ReadView( PrismGraph graph, JsonObject json )
	{
		graph.View ??= new ViewState();

		json ??= new JsonObject();

		graph.View.Center = ReadVector2( json["center"] );
		graph.View.Zoom = Geometry( ValueCodec.NumberOf( json["zoom"], 1f ), 1f );
	}

	static void ReplaceParameters( PrismGraph graph, JsonArray array, DiagnosticSink sink )
	{
		foreach ( var parameter in graph.Parameters.ToArray() )
		{
			graph.RemoveParameter( parameter.Id );
		}

		ReadParameters( graph, array, sink );
	}

	static void ReplaceKeywords( PrismGraph graph, JsonArray array, DiagnosticSink sink )
	{
		foreach ( var keyword in graph.Keywords.ToArray() )
		{
			graph.RemoveKeyword( keyword.Id );
		}

		ReadKeywords( graph, array, sink );
	}

	static void ReplaceGroups( PrismGraph graph, JsonArray array, DiagnosticSink sink )
	{
		foreach ( var group in graph.Groups.ToArray() )
		{
			graph.RemoveGroup( group.Id );
		}

		ReadGroups( graph, array, sink );
	}

	static void ReplaceNotes( PrismGraph graph, JsonArray array, DiagnosticSink sink )
	{
		foreach ( var note in graph.Notes.ToArray() )
		{
			graph.RemoveNote( note.Id );
		}

		ReadNotes( graph, array, sink );
	}

	// ---------------------------------------------------------------- helpers ----

	static bool SameType( PrismNode node, JsonObject json )
	{
		var wanted = NodeMigrations.ResolveTypeId( ValueCodec.StringOf( json["type"] ) ?? string.Empty );

		var actual = node switch
		{
			UnknownNode unknown => unknown.TypeId,
			_ => node.Descriptor?.Id
		};

		return string.Equals( wanted, actual, StringComparison.OrdinalIgnoreCase );
	}

	static void ReorderNodes( PrismGraph graph, IReadOnlyList<NodeId> wanted )
	{
		if ( wanted.Count != graph.Nodes.Count ) return;

		var ordered = new List<PrismNode>( wanted.Count );

		foreach ( var id in wanted )
		{
			var node = graph.FindNode( id );

			if ( node is null ) return;

			ordered.Add( node );
		}

		graph.ReorderNodes( ordered );
	}

	/// <summary>
	/// Rewrite every reference to a parameter that was renumbered on paste.
	/// <para>
	/// A blackboard reference is a <see cref="ParamId"/>-typed property, which serializes as a bare id
	/// string (see <c>PrismJson</c>), so this is a string rewrite by value. It recurses through nested
	/// objects and arrays because a node may keep a table of them — a texture array's per-slice
	/// parameter, say — rather than one property per reference.
	/// </para>
	/// </summary>
	static void RemapParameterIds( JsonObject node, Dictionary<string, string> map )
	{
		if ( node["props"] is not JsonObject props ) return;

		RemapIdStrings( props, map, 0 );
	}

	static void RemapIdStrings( JsonNode container, Dictionary<string, string> map, int depth )
	{
		// Documents are shallow; the cap is only there so a hand-edited file cannot spin this forever.
		if ( container is null || depth > 16 ) return;

		switch ( container )
		{
			case JsonObject obj:
			{
				foreach ( var key in obj.Select( x => x.Key ).ToArray() )
				{
					if ( Replace( obj[key], map ) is { } replacement )
					{
						obj[key] = replacement;
						continue;
					}

					RemapIdStrings( obj[key], map, depth + 1 );
				}

				break;
			}

			case JsonArray array:
			{
				for ( int i = 0; i < array.Count; i++ )
				{
					if ( Replace( array[i], map ) is { } replacement )
					{
						array[i] = replacement;
						continue;
					}

					RemapIdStrings( array[i], map, depth + 1 );
				}

				break;
			}
		}
	}

	static string Replace( JsonNode node, Dictionary<string, string> map )
	{
		if ( node is not JsonValue value ) return null;
		if ( !value.TryGetValue<string>( out var text ) || string.IsNullOrEmpty( text ) ) return null;

		return map.TryGetValue( text, out var replacement ) ? replacement : null;
	}

	static DateTimeOffset? ParseTime( JsonNode node )
	{
		var text = ValueCodec.StringOf( node );

		if ( string.IsNullOrWhiteSpace( text ) ) return null;

		return DateTimeOffset.TryParse( text, CultureInfo.InvariantCulture,
			DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed )
			? parsed
			: null;
	}

	static string FormatTime( DateTimeOffset time ) =>
		time.ToUniversalTime().ToString( "yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture );

	static T ReadEnum<T>( JsonNode node, T fallback ) where T : struct, Enum
	{
		var text = ValueCodec.StringOf( node );

		if ( !string.IsNullOrEmpty( text ) && Enum.TryParse<T>( text, true, out var parsed ) ) return parsed;

		// A numeric enum has to survive whatever CLR type is behind the JSON value, so it goes through
		// the same tolerant number reader as everything else rather than asking for int specifically.
		if ( ValueCodec.IsNumber( node ) )
		{
			var number = ValueCodec.NumberOf( node );

			if ( float.IsFinite( number ) && number == (int)number && Enum.IsDefined( typeof( T ), (int)number ) )
			{
				return (T)Enum.ToObject( typeof( T ), (int)number );
			}
		}

		return fallback;
	}

	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<string>( out var s ) ) return bool.TryParse( s, out var parsed ) ? parsed : fallback;
		if ( ValueCodec.IsNumber( value ) ) return ValueCodec.NumberOf( value ) != 0f;

		return fallback;
	}

	static float? ReadNullableFloat( JsonNode node )
	{
		if ( node is not JsonValue ) return null;

		var value = ValueCodec.NumberOf( node );

		// A non-finite bound or pad fill is not a value the editor can do anything with: it would go
		// straight into generated code as `float3( v, NaN )`. Treat it as "not specified".
		return float.IsFinite( value ) ? value : null;
	}

	/// <summary>
	/// A coordinate the editor can actually draw with.
	/// <para>
	/// <c>NaN</c> and the infinities are legal in a shader literal and are round-tripped as such by
	/// <see cref="ValueCodec"/>, but as <em>geometry</em> they are poison: a card at NaN is invisible
	/// and unselectable, and one of them turns every bounding box that unions it — fit-to-view, the
	/// minimap, marquee selection, group auto-size — into NaN as well. A corrupt coordinate therefore
	/// degrades to the origin, where the user can find the node and move it.
	/// </para>
	/// </summary>
	static float Geometry( float value, float fallback = 0f ) => float.IsFinite( value ) ? value : fallback;

	/// <summary>
	/// A string field, with the empty string normalised to null. The writer omits empty values, so
	/// without this an empty string read back as a present-but-empty key and the next write dropped it —
	/// making the document change every time it was saved.
	/// </summary>
	/// <summary>
	/// Drop a byte-order mark and any leading whitespace before the JSON starts.
	/// <para>
	/// <c>JsonNode.Parse( string )</c> rejects a leading U+FEFF outright, so a document that any other
	/// editor saved with a BOM would otherwise fail to open at all — with "not valid JSON", pointing at
	/// character zero, which is exactly as unhelpful as it sounds. Text arrives here from the clipboard,
	/// from the asset system and from autosave recovery as well as from our own writer, and only the
	/// last of those is guaranteed BOM-free.
	/// </para>
	/// </summary>
	static string StripPreamble( string json )
	{
		const char ByteOrderMark = (char)0xFEFF;
		const char ZeroWidthSpace = (char)0x200B;

		if ( string.IsNullOrEmpty( json ) ) return json;

		var start = 0;

		while ( start < json.Length && ( json[start] == ByteOrderMark || json[start] == ZeroWidthSpace ||
			char.IsWhiteSpace( json[start] ) ) )
		{
			start++;
		}

		return start == 0 ? json : json[start..];
	}

	static string Text( JsonNode node )
	{
		var value = ValueCodec.StringOf( node );

		return string.IsNullOrEmpty( value ) ? null : value;
	}

	/// <summary>
	/// The node-type version a document declares, clamped to something a build could plausibly have
	/// written. A corrupt <c>v</c> — a float that cast to <see cref="int.MinValue"/>, say — would
	/// otherwise be walked one version at a time by the migration chain.
	/// </summary>
	static int ReadVersion( JsonNode node, int fallback = 1 )
	{
		var raw = ValueCodec.NumberOf( node, fallback );

		if ( !float.IsFinite( raw ) ) return fallback;

		return (int)Math.Clamp( raw, 0f, 1_000_000f );
	}

	static List<string> ReadStrings( JsonNode node )
	{
		if ( node is not JsonArray array ) return null;

		var result = new List<string>( array.Count );

		foreach ( var element in array )
		{
			var text = ValueCodec.StringOf( element );

			if ( text is null ) continue;

			result.Add( text );
		}

		return result;
	}

	static JsonArray WriteStrings( IEnumerable<string> values )
	{
		var array = new JsonArray();

		foreach ( var value in values ?? Array.Empty<string>() )
		{
			array.Add( JsonValue.Create( value ) );
		}

		return array;
	}

	static float[] ReadFloats( JsonNode node )
	{
		if ( node is not JsonArray array ) return Array.Empty<float>();

		var result = new float[array.Count];

		for ( int i = 0; i < array.Count; i++ )
		{
			result[i] = Geometry( ValueCodec.NumberOf( array[i] ) );
		}

		return result;
	}

	static Vector2 ReadVector2( JsonNode node )
	{
		var values = ReadFloats( node );

		return new Vector2( At( values, 0 ), At( values, 1 ) );
	}

	static Vector2[] ReadVia( JsonArray array )
	{
		if ( array is null || array.Count == 0 ) return null;

		var result = new List<Vector2>( array.Count );

		foreach ( var element in array )
		{
			result.Add( ReadVector2( element ) );
		}

		return result.ToArray();
	}

	static float At( float[] values, int index, float fallback = 0f ) =>
		values is not null && index < values.Length ? values[index] : fallback;

	static JsonObject ExtraBag( JsonObject source, string[] known )
	{
		if ( source is null ) return null;

		JsonObject bag = null;

		if ( source["x"] is JsonObject declared ) bag = declared.DeepClone() as JsonObject;

		foreach ( var pair in source )
		{
			if ( pair.Key == "x" ) continue;
			if ( known.Contains( pair.Key, StringComparer.Ordinal ) ) continue;

			bag ??= new JsonObject();
			bag[pair.Key] = pair.Value?.DeepClone();
		}

		return bag;
	}

	static void MergeExtra( JsonObject target, JsonObject extra )
	{
		if ( target is null || extra is null || extra.Count == 0 ) return;

		target["x"] = extra.DeepClone();
	}

	static readonly string[] s_documentKeys =
	{
		KeySchema, KeyId, KeyKind, "meta", "settings", "preview", "parameters", "keywords",
		"nodes", "edges", "groups", "notes", "view"
	};

	static readonly string[] s_settingsKeys =
	{
		"domain", "shadingModel", "blendMode", "cullMode", "modes", "targets", "hlslDialect",
		"uv2", "renderBackfaces", "debugSymbols", "strictTypes"
	};

	static readonly string[] s_parameterKeys = { KeyId, "name", "type", "default", "attribute", "ui" };

	// ---------------------------------------------------------------- self test ----

	/// <summary>
	/// Round-trip the worked example from the architecture brief and report what held and what did
	/// not, as plain text.
	/// <para>
	/// There is no test framework in this environment, so correctness has to be <em>inspectable</em>:
	/// call this from a console command or a menu item and read the report. It checks the three things
	/// that matter — ids are never renumbered, an unregistered node comes back byte-identical apart
	/// from its position, and writing is idempotent — plus the value codec and the cycle detector.
	/// </para>
	/// </summary>
	public static string RunSelfTest()
	{
		var report = new StringBuilder();
		var passed = 0;
		var failed = 0;

		void Check( string what, bool ok, string detail = null )
		{
			if ( ok )
			{
				passed++;
				report.AppendLine( $"  PASS  {what}" );
				return;
			}

			failed++;
			report.AppendLine( $"  FAIL  {what}{( string.IsNullOrEmpty( detail ) ? "" : $" — {detail}" )}" );
		}

		report.AppendLine( $"Prism serializer self-test — {DateTime.Now:HH:mm:ss}" );
		report.AppendLine();

		var sink = new DiagnosticSink();
		var graph = Read( SelfTestDocument, sink );

		Check( "document parses", graph is not null );

		if ( graph is null )
		{
			report.AppendLine();
			report.AppendLine( $"{passed} passed, {failed} failed." );
			return report.ToString();
		}

		Check( "document id preserved", graph.DocumentId == "g4k2x9pq", graph.DocumentId );
		Check( "kind is shader", !graph.IsSubgraph );
		Check( "title preserved", graph.Meta.Title == "Wet Stone", graph.Meta.Title );
		Check( "settings.domain", graph.Settings.Domain == ShaderDomain.Surface );
		Check( "settings.blendMode", graph.Settings.BlendMode == SurfaceBlendMode.Opaque );
		Check( "settings.uv2", graph.Settings.Uv2 );
		Check( "settings.modes", graph.Settings.Modes.Count == 3, string.Join( ",", graph.Settings.Modes ) );
		Check( "settings.targets", graph.Settings.WantsTarget( PrismConstants.BackendSlang ) );
		Check( "preview mesh", graph.Preview.Mesh == "Sphere", graph.Preview.Mesh );
		Check( "preview camera", Math.Abs( graph.Preview.Camera.Distance - 150f ) < 0.001f );

		Check( "3 parameters", graph.Parameters.Count == 3, $"{graph.Parameters.Count}" );
		Check( "parameter ids preserved", graph.FindParameter( ParamId.Parse( "p2" ) ) is not null );

		var roughness = graph.FindParameter( ParamId.Parse( "p2" ) );
		Check( "parameter type parsed", roughness is not null && roughness.Type == ShaderType.Float );
		Check( "parameter default preserved",
			roughness?.Default is float f && Math.Abs( f - 0.62f ) < 0.0001f, $"{roughness?.Default}" );
		Check( "parameter ui slider", roughness?.Ui.Control == UiControl.Slider );
		Check( "parameter ui group", roughness?.Ui.Group == "Surface" );

		var baseColor = graph.FindParameter( ParamId.Parse( "p1" ) );
		Check( "texture parameter type", baseColor is not null && baseColor.Type.IsTexture );
		Check( "texture default is a descriptor",
			baseColor?.Default is TextureValue { Path: "materials/dev/white_color.tga" } );

		Check( "1 keyword", graph.Keywords.Count == 1 );
		Check( "keyword normalized", graph.Keywords.Count == 1 && graph.Keywords[0].NormalizedName == "F_PUDDLES" );

		Check( "9 nodes", graph.Nodes.Count == 9, $"{graph.Nodes.Count}" );
		Check( "node ids preserved", graph.FindNode( NodeId.Parse( "n9" ) ) is not null );
		Check( "7 edges resolved or ghosted",
			graph.Edges.Count + graph.BrokenEdges.Count == 7,
			$"{graph.Edges.Count} live, {graph.BrokenEdges.Count} broken" );

		Check( "1 group", graph.Groups.Count == 1 );
		Check( "1 note", graph.Notes.Count == 1 );
		Check( "view zoom", Math.Abs( graph.View.Zoom - 0.9f ) < 0.0001f );

		// The unknown-node guarantee: byte-identical apart from pos.
		var unknown = graph.FindNode( NodeId.Parse( "n9" ) ) as UnknownNode;
		Check( "n9 loaded as an unknown node", unknown is not null );

		if ( unknown is not null )
		{
			var source = JsonNode.Parse( SelfTestDocument ) as JsonObject;
			var original = ( source?["nodes"] as JsonArray )?
				.OfType<JsonObject>()
				.FirstOrDefault( x => ValueCodec.StringOf( x[KeyId] ) == "n9" );

			var written = WriteNode( graph, unknown );

			if ( original is not null )
			{
				var a = original.DeepClone() as JsonObject;
				var b = written.DeepClone() as JsonObject;
				a.Remove( "pos" );
				b.Remove( "pos" );

				Check( "n9 round-trips byte-identically apart from pos",
					a.ToJsonString() == b.ToJsonString(),
					$"{a.ToJsonString()} != {b.ToJsonString()}" );
			}

			Check( "n9 preserves its x bag",
				ValueCodec.StringOf( unknown.Raw?["x"]?["vendor"] ) == "acme" );
			Check( "n9 reconstructed no ports (it has no edges)",
				unknown.Inputs.Count == 0 && unknown.Outputs.Count == 0 );
		}

		// Determinism: two writes of the same document must be identical, and a reparse must too.
		var first = Write( graph );
		var second = Write( graph );
		Check( "writing is deterministic", first == second );

		var reread = Read( first, new DiagnosticSink() );
		Check( "reparse succeeds", reread is not null );

		if ( reread is not null )
		{
			var third = Write( reread );
			Check( "write is idempotent across a reparse", first == third,
				$"{first.Length} vs {third.Length} chars" );
			Check( "node ids survive a reparse",
				reread.Nodes.Select( x => x.Id.Value ).SequenceEqual( graph.Nodes.Select( x => x.Id.Value ) ) );
			Check( "edge ids survive a reparse",
				reread.Edges.Select( x => x.Id.Value ).SequenceEqual( graph.Edges.Select( x => x.Id.Value ) ) );
		}

		// The value codec.
		Check( "float round-trips exactly",
			ValueCodec.Read( ShaderType.Float, ValueCodec.Write( ShaderType.Float, 0.1f ) ) is float rf &&
			rf.Equals( 0.1f ) );
		Check( "float3 round-trips",
			ValueCodec.Read( ShaderType.Float3, ValueCodec.Write( ShaderType.Float3,
				new Vector3( 1f, 2f, 3f ) ) ) is Vector3 v && v.z.Equals( 3f ) );
		Check( "colour round-trips through r,g,b,a",
			ValueCodec.TryParseColor( ValueCodec.FormatColor( new Color( 0.25f, 0.5f, 0.75f, 1f ) ),
				out var colour ) && colour.g.Equals( 0.5f ) );
		Check( "bool round-trips",
			ValueCodec.Read( ShaderType.Bool, ValueCodec.Write( ShaderType.Bool, true ) ) is true );

		// Malformed input must degrade, never throw.
		var junkSink = new DiagnosticSink();
		Check( "malformed JSON is reported, not thrown", Read( "{ this is not json", junkSink ) is null );
		Check( "malformed JSON produced a diagnostic", junkSink.HasErrors );

		var partialSink = new DiagnosticSink();
		var partial = Read( "{ \"schema\": 1, \"nodes\": [ 5, { \"id\": \"a\" } ], \"edges\": [ {} ] }", partialSink );
		Check( "a document with junk entries still loads", partial is not null );
		Check( "junk entries are reported", partialSink.Count > 0 );

		// Cycle detection, including through a node with no registered type.
		if ( reread is not null )
		{
			Check( "the example graph is acyclic", !GraphQueries.TryFindCycle( reread, out _ ) );
		}

		report.AppendLine();
		report.AppendLine( "Registered nodes, undo and import:" );

		RegisteredNodeTests( Check );
		CycleTests( Check );
		UndoTests( Check );
		LegacyImportTests( Check );

		report.AppendLine();
		report.AppendLine( "Fault injection:" );

		DiagnosticCodeTests( Check );
		ValueCodecStabilityTests( Check );
		LiteralShapeTests( Check );
		LibraryLiteralTests( Check );
		KeywordNameTests( Check );
		CorruptionTests( Check );
		DocumentStormTests( Check );
		MalformedDocumentTests( Check );
		GraphIndexTests( Check );
		MutationGuardTests( Check );
		RestoreEquivalenceTests( Check );
		UndoStormTests( Check );
		InterleavedUndoTests( Check );
		UndoCapacityTests( Check );
		ScaleTests( Check );
		SubgraphRecoveryTests( Check );
		PasteSemanticTests( Check );
		ClipboardFuzzTests( Check );
		LegacyCorruptionTests( Check );
		SaveIntegrityTests( Check );
		GameResourceTests( Check );
		CompileServiceTests( Check );
		RecoveryTests( Check );

		report.AppendLine();
		report.AppendLine( $"{passed} passed, {failed} failed." );

		if ( sink.Count > 0 )
		{
			report.AppendLine();
			report.AppendLine( "Diagnostics from the initial read:" );

			foreach ( var diagnostic in sink.All )
			{
				report.AppendLine( $"  {diagnostic}" );
			}
		}

		return report.ToString();
	}

	/// <summary>The first line where two documents diverge, so a failed comparison says something useful.</summary>
	static string Diff( string expected, string actual )
	{
		if ( string.Equals( expected, actual, StringComparison.Ordinal ) ) return null;

		var a = ( expected ?? string.Empty ).Replace( "\r\n", "\n" ).Split( '\n' );
		var b = ( actual ?? string.Empty ).Replace( "\r\n", "\n" ).Split( '\n' );

		for ( int i = 0; i < Math.Max( a.Length, b.Length ); i++ )
		{
			var left = i < a.Length ? a[i] : "<end>";
			var right = i < b.Length ? b[i] : "<end>";

			if ( string.Equals( left, right, StringComparison.Ordinal ) ) continue;

			var context = string.Join( " | ", b.Skip( i ).Take( 8 ).Select( x => x.Trim() ) );

			return $"line {i + 1}: expected `{left.Trim()}` got `{right.Trim()}` " +
				$"({a.Length} vs {b.Length} lines) :: {context}";
		}

		return $"{a.Length} vs {b.Length} lines";
	}

	/// <summary>Exercise the registered-node path: properties, inline literals and dynamic ports.</summary>
	static void RegisteredNodeTests( Action<string, bool, string> check )
	{
		var probe = NodeRegistry.Create( PrismSelfTestNode.TypeId ) as PrismSelfTestNode;

		check( "the self-test probe node is registered", probe is not null, null );

		if ( probe is null ) return;

		var graph = new PrismGraph();

		probe.Label = "hello";
		probe.Channel = 7;
		probe.Mode = SelfTestMode.Gamma;
		probe.Enabled = false;
		probe.ExtraPorts = 2;
		probe.DefaultA = 0.125f;
		probe.DefaultB = new Vector3( 4f, 5f, 6f );
		probe.Position = new Vector2( 32f, -64f );
		probe.Flags = NodeFlags.Preview | NodeFlags.Collapsed;

		graph.AddNode( probe );

		var second = NodeRegistry.Create( PrismSelfTestNode.TypeId );
		second.Position = new Vector2( 256f, 0f );
		graph.AddNode( second );

		var edge = graph.Connect( new PortRef( second.Id, PortId.Parse( "Out" ) ),
			new PortRef( probe.Id, PortId.Parse( "B" ) ) );

		check( "a float3 output connects to a float3 input", edge is not null, null );

		var illegal = graph.CanConnect( new PortRef( second.Id, PortId.Parse( "Out" ) ),
			new PortRef( probe.Id, PortId.Parse( "Out" ) ), out _ );

		check( "an output cannot be connected to an output", !illegal, null );

		var text = Write( graph );
		var loaded = Read( text, new DiagnosticSink() );

		check( "a registered node round-trips", loaded is not null, null );

		if ( loaded is null ) return;

		var reloaded = loaded.FindNode( probe.Id ) as PrismSelfTestNode;

		check( "the node came back as its own type", reloaded is not null, reloaded?.GetType().Name );

		if ( reloaded is null ) return;

		check( "string property survives", reloaded.Label == "hello", reloaded.Label );
		check( "int property survives", reloaded.Channel == 7, $"{reloaded.Channel}" );
		check( "enum property survives", reloaded.Mode == SelfTestMode.Gamma, $"{reloaded.Mode}" );
		check( "bool property survives", !reloaded.Enabled, null );
		check( "position survives", reloaded.Position == new Vector2( 32f, -64f ), $"{reloaded.Position}" );
		check( "flags survive", reloaded.Flags == ( NodeFlags.Preview | NodeFlags.Collapsed ), null );
		check( "inline float survives", reloaded.DefaultA.Equals( 0.125f ), $"{reloaded.DefaultA}" );
		check( "inline vector survives", reloaded.DefaultB == new Vector3( 4f, 5f, 6f ), $"{reloaded.DefaultB}" );
		check( "dynamic ports are rebuilt after load",
			reloaded.FindInput( PortId.Parse( "Extra1" ) ) is not null,
			string.Join( ",", reloaded.Inputs.Select( x => x.Id.Value ) ) );
		check( "the port inline slot mirrors the bound property",
			ValueCodec.Equal( reloaded.FindInput( PortId.Parse( "A" ) )?.InlineValue, 0.125f ), null );
		check( "the connection survives", loaded.Edges.Count == 1, $"{loaded.Edges.Count}" );

		// Default inline values are not written, so a document stays small.
		var fresh = new PrismGraph();
		fresh.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

		var freshJson = JsonNode.Parse( Write( fresh ) ) as JsonObject;
		var freshNode = ( freshJson?["nodes"] as JsonArray )?.OfType<JsonObject>().FirstOrDefault();

		check( "default inline values are omitted", freshNode is not null && !freshNode.ContainsKey( "inline" ),
			freshNode?.ToJsonString() );

		// Removing a port turns its edge into a visible ghost rather than deleting it silently.
		reloaded.ExtraPorts = 0;
		NodeProperties.RebuildPorts( reloaded );

		check( "shrinking the port set does not lose the surviving connection",
			loaded.Edges.Count == 1 && loaded.BrokenEdges.Count == 0,
			$"{loaded.Edges.Count} live, {loaded.BrokenEdges.Count} broken" );

		var target = loaded.FindNode( probe.Id );
		var ghostGraph = loaded;
		var doomed = ghostGraph.Edges.FirstOrDefault();

		if ( doomed is not null && target is PrismSelfTestNode typed )
		{
			typed.HideB = true;
			NodeProperties.RebuildPorts( typed );

			check( "removing a connected port produces a broken-edge ghost",
				ghostGraph.BrokenEdges.Count == 1 && ghostGraph.Edges.Count == 0,
				$"{ghostGraph.Edges.Count} live, {ghostGraph.BrokenEdges.Count} broken" );

			check( "a ghost survives a save and reload",
				Read( Write( ghostGraph ), new DiagnosticSink() )?.BrokenEdges.Count == 1, null );

			typed.HideB = false;
			NodeProperties.RebuildPorts( typed );
			ghostGraph.RepairBrokenEdges();

			check( "restoring the port repairs the ghost",
				ghostGraph.Edges.Count == 1 && ghostGraph.BrokenEdges.Count == 0,
				$"{ghostGraph.Edges.Count} live, {ghostGraph.BrokenEdges.Count} broken" );
		}
	}

	/// <summary>Cycle detection has to see through every node, reroutes included.</summary>
	static void CycleTests( Action<string, bool, string> check )
	{
		var graph = new PrismGraph();
		var nodes = new List<PrismNode>();

		for ( int i = 0; i < 3; i++ )
		{
			var node = NodeRegistry.Create( PrismSelfTestNode.TypeId );

			if ( node is null ) return;

			node.Position = new Vector2( i * 200f, 0f );
			nodes.Add( graph.AddNode( node ) );
		}

		var outPort = PortId.Parse( "Out" );
		var inPort = PortId.Parse( "B" );

		graph.AddEdge( Edge.Between( graph.NewEdgeId(),
			new PortRef( nodes[0].Id, outPort ), new PortRef( nodes[1].Id, inPort ) ) );
		graph.AddEdge( Edge.Between( graph.NewEdgeId(),
			new PortRef( nodes[1].Id, outPort ), new PortRef( nodes[2].Id, inPort ) ) );

		check( "a chain is acyclic", !GraphQueries.TryFindCycle( graph, out _ ), null );

		check( "closing the loop is refused at drop time",
			!graph.CanConnect( new PortRef( nodes[2].Id, outPort ), new PortRef( nodes[0].Id, inPort ), out _ ),
			null );

		// Force it in the way a corrupt document would, and make sure the detector catches it.
		graph.AddEdge( Edge.Between( graph.NewEdgeId(),
			new PortRef( nodes[2].Id, outPort ), new PortRef( nodes[0].Id, inPort ) ) );

		var found = GraphQueries.TryFindCycle( graph, out var cycle );

		check( "a forced cycle is detected", found, null );
		check( "the full cycle path is reported", found && cycle.Count == 4,
			string.Join( " -> ", cycle.Select( x => x.Value ) ) );
		check( "the cycle description names every node",
			found && GraphQueries.DescribeCycle( graph, cycle ).Split( '→' ).Length == 4, null );

		var order = GraphQueries.TopologicalOrder( graph );

		check( "a cyclic graph still yields every node in some order", order.Count == 3, $"{order.Count}" );

		var acyclic = new PrismGraph();
		var a = acyclic.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
		var b = acyclic.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
		var c = acyclic.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

		acyclic.AddEdge( Edge.Between( acyclic.NewEdgeId(),
			new PortRef( a.Id, outPort ), new PortRef( b.Id, inPort ) ) );
		acyclic.AddEdge( Edge.Between( acyclic.NewEdgeId(),
			new PortRef( b.Id, outPort ), new PortRef( c.Id, inPort ) ) );

		var sorted = GraphQueries.TopologicalOrder( acyclic );

		check( "topological order puts dependencies first",
			sorted.Count == 3 && sorted[0] == a.Id && sorted[1] == b.Id && sorted[2] == c.Id,
			string.Join( ",", sorted.Select( x => x.Value ) ) );

		check( "the dependency subtree of the tail is the whole chain",
			GraphQueries.DependencySubtree( acyclic, c.Id ).Count == 3, null );

		var orphan = acyclic.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

		check( "an unconnected node is reported as an orphan",
			GraphQueries.Orphans( acyclic, new[] { c.Id } ).Contains( orphan.Id ), null );
	}

	/// <summary>
	/// The acceptance criterion for undo: a long run of randomised edits, undone all the way, must
	/// leave the document byte-identical to where it started — and redone all the way, byte-identical
	/// to where it ended.
	/// </summary>
	static void UndoTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;

		var graph = new PrismGraph();
		var undo = new PrismUndoStack( graph ) { CoalesceMs = 0 };
		var mutations = new GraphMutations( graph, undo );

		for ( int i = 0; i < 4; i++ )
		{
			mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( i * 192f, 0f ) );
		}

		mutations.AddParameter( "Roughness", ShaderType.Float );

		undo.Clear();

		var start = Write( graph );
		var startIds = graph.Nodes.Select( x => x.Id ).ToArray();
		var firstNode = graph.Nodes.FirstOrDefault();

		var random = new Random( 20260810 );
		var outPort = PortId.Parse( "Out" );
		var inPort = PortId.Parse( "B" );

		for ( int step = 0; step < 50; step++ )
		{
			var nodes = graph.Nodes.ToArray();

			switch ( random.Next( 8 ) )
			{
				case 0:
					mutations.AddNode( PrismSelfTestNode.TypeId,
						new Vector2( random.Next( -512, 512 ), random.Next( -512, 512 ) ) );
					break;

				case 1 when nodes.Length > 2:
					mutations.RemoveNode( nodes[random.Next( nodes.Length )].Id );
					break;

				case 2 when nodes.Length > 0:
					mutations.Move( nodes[random.Next( nodes.Length )].Id,
						new Vector2( random.Next( -512, 512 ), random.Next( -512, 512 ) ) );
					break;

				case 3 when nodes.Length > 1:
				{
					var from = nodes[random.Next( nodes.Length )];
					var to = nodes[random.Next( nodes.Length )];

					mutations.Connect( new PortRef( from.Id, outPort ), new PortRef( to.Id, inPort ) );
					break;
				}

				case 4 when graph.Edges.Count > 0:
					mutations.Disconnect( graph.Edges[random.Next( graph.Edges.Count )].Id );
					break;

				case 5 when nodes.Length > 0:
					mutations.SetProperty( nodes[random.Next( nodes.Length )].Id, "Channel", random.Next( 16 ) );
					break;

				case 6 when nodes.Length > 0:
					mutations.SetInlineValue( nodes[random.Next( nodes.Length )].Id, PortId.Parse( "A" ),
						(float)random.NextDouble() );
					break;

				case 7 when graph.Parameters.Count > 0:
					mutations.SetParameterDefault( graph.Parameters[random.Next( graph.Parameters.Count )].Id,
						(float)random.NextDouble() );
					break;
			}
		}

		var end = Write( graph );

		check( "50 randomised edits changed the document", start != end, null );
		check( "every edit produced an undo entry", undo.Count > 0, $"{undo.Count} entries" );

		var guard = 0;

		while ( undo.CanUndo && guard++ < 500 )
		{
			undo.Undo();
		}

		check( "undoing everything restores the original document byte for byte", Write( graph ) == start,
			Diff( start, Write( graph ) ) );

		check( "node identity survives the restore",
			firstNode is not null && ReferenceEquals( graph.FindNode( firstNode.Id ), firstNode ), null );

		check( "every original node id is back",
			startIds.All( id => graph.FindNode( id ) is not null ), null );

		guard = 0;

		while ( undo.CanRedo && guard++ < 500 )
		{
			undo.Redo();
		}

		check( "redoing everything restores the final document byte for byte", Write( graph ) == end, null );

		check( "jumping to level zero works", undo.JumpTo( 0 ) && Write( graph ) == start, null );
		check( "jumping to the top works", undo.JumpTo( undo.Count ) && Write( graph ) == end, null );

		// Coalescing: rapid same-label edits become one step.
		var coalescing = new PrismUndoStack( graph ) { CoalesceMs = 5000 };
		var slider = new GraphMutations( graph, coalescing );
		var target = graph.Nodes.FirstOrDefault();

		if ( target is not null )
		{
			for ( int i = 1; i <= 10; i++ )
			{
				slider.SetInlineValue( target.Id, PortId.Parse( "A" ), i * 0.05f, "Set A" );
			}

			check( "ten rapid same-label edits coalesce into one step", coalescing.Count == 1,
				$"{coalescing.Count} entries" );

			coalescing.Undo();

			check( "undoing the coalesced step reverses all ten", coalescing.Level == 0, null );
		}

		// A no-op edit must not create a step.
		var quiet = new PrismUndoStack( graph );
		var quietMutations = new GraphMutations( graph, quiet );

		using ( quietMutations.Begin( "Nothing" ) ) { }

		check( "an edit that changed nothing records no step", quiet.Count == 0, $"{quiet.Count}" );

		// Balance: a stray commit is reported and survived rather than corrupting the stack.
		quiet.Commit();

		check( "an unbalanced commit does not corrupt the stack", !quiet.IsCapturing && quiet.Count == 0, null );
	}

	/// <summary>Import a representative built-in shader graph and check what came out.</summary>
	static void LegacyImportTests( Action<string, bool, string> check )
	{
		var sink = new DiagnosticSink();
		var graph = LegacyShaderGraphImporter.Import( LegacyDocument, false, sink );

		check( "a legacy .shdrgrph imports", graph is not null, null );

		if ( graph is null ) return;

		check( "the legacy blend mode carries over", graph.Settings.BlendMode == SurfaceBlendMode.Masked,
			$"{graph.Settings.BlendMode}" );
		check( "every legacy node is imported", graph.Nodes.Count == 7, $"{graph.Nodes.Count}" );
		check( "legacy ids are replaced with stable base36 ids",
			graph.Nodes.All( x => Ids.IsWellFormed( x.Id.Value ) && x.Id.Value.Length == Ids.ShortIdLength ),
			string.Join( ",", graph.Nodes.Select( x => x.Id.Value ) ) );
		check( "connections became a real edge list",
			graph.Edges.Count + graph.BrokenEdges.Count == 4,
			$"{graph.Edges.Count} live, {graph.BrokenEdges.Count} broken" );
		check( "a named legacy constant became a blackboard parameter",
			graph.Parameters.Count == 1 && graph.Parameters[0].Name == "RimFudge",
			string.Join( ",", graph.Parameters.Select( x => x.Name ) ) );
		check( "the promoted parameter kept its default",
			graph.Parameters.Count == 1 && graph.Parameters[0].Default is float f && f.Equals( 0.25f ),
			$"{graph.Parameters.FirstOrDefault()?.Default}" );

		// An unnamed legacy constant used to import as zero, because its Value landed on a property the
		// constant node does not have. Nothing failed and nothing warned; the maths just quietly changed.
		var constant = graph.Nodes.OfType<Nodes.ConstantNode>().FirstOrDefault();

		check( "an unnamed legacy constant imports as a constant node", constant is not null, null );
		check( "a legacy Float4 imports as a colour, not a float4",
			constant?.Kind == Nodes.PrismConstantKind.Color, $"{constant?.Kind}" );
		check( "the constant kept its value",
			constant is not null && constant.ColorValue.r.AlmostEqual( 1f )
				&& constant.ColorValue.g.AlmostEqual( 0.5f )
				&& constant.ColorValue.b.AlmostEqual( 0.25f ),
			$"{constant?.ColorValue}" );

		// A Float4's component plugs are R/G/B/A in the built-in editor. They only line up if the kind
		// is Color, so this edge is what proves the two fixes agree with each other.
		check( "an edge leaving a constant's component plug survives",
			graph.BrokenEdges.Count == 0, string.Join( ",", graph.BrokenEdges.Select( x => x.Edge.ToString() ) ) );

		// A legacy TextureSampler is a parameter and a sampler in one node, so it stays a sampler and
		// keeps its slot rather than being promoted to a bare blackboard reference. Every field it
		// carried used to be dropped on the floor, taking the texture path with it.
		var sampler = graph.Nodes.OfType<Nodes.TextureNode>().FirstOrDefault();

		check( "a legacy texture sampler imports as a texture node", sampler is not null,
			string.Join( ",", graph.Nodes.Select( x => x.Descriptor?.Id ?? "?" ) ) );
		check( "the texture asset path survives the import",
			sampler?.DefaultTexture == "textures/fixture/rust_albedo.png", sampler?.DefaultTexture );
		check( "the texture slot keeps its name and group",
			sampler?.TextureName == "RustAlbedo" && sampler?.TextureGroup == "Surface",
			$"{sampler?.TextureName} / {sampler?.TextureGroup}" );
		check( "the texture keeps its colour space", sampler is { Srgb: false }, $"{sampler?.Srgb}" );
		check( "legacy sampler state is translated, not copied verbatim",
			sampler is { Filter: Nodes.PrismTextureFilter.Anisotropic, AddressU: Nodes.PrismTextureAddress.Clamp,
				AddressV: Nodes.PrismTextureAddress.Mirror },
			$"{sampler?.Filter} / {sampler?.AddressU} / {sampler?.AddressV}" );
		check( "a texture sampler is not promoted to a blackboard parameter",
			graph.Parameters.All( x => x.Name != "RustAlbedo" ),
			string.Join( ",", graph.Parameters.Select( x => x.Name ) ) );

		var edges = graph.Edges.Concat( graph.BrokenEdges.Select( x => x.Edge ) ).ToArray();

		check( "every imported connection pins the legacy zero pad fill",
			edges.Length > 0 && edges.All( x => x.Fill is { } fill && fill.Equals( 0f ) ), null );

		var unmapped = graph.Nodes.OfType<UnknownNode>()
			.FirstOrDefault( x => x.TypeId.EndsWith( "TotallyMadeUpNode", StringComparison.Ordinal ) );

		check( "an unmapped legacy node is preserved rather than dropped", unmapped is not null, null );
		check( "the unmapped node kept its original properties",
			unmapped?.Raw?["SecretSetting"] is not null, unmapped?.Raw?.ToJsonString() );

		check( "the imported graph round-trips", Read( Write( graph ), new DiagnosticSink() ) is not null, null );
		check( "the importer reported what it did", sink.Count > 0, null );
	}

	// ---------------------------------------------------------------- fault injection ----

	/// <summary>
	/// The code table itself. A duplicate or a malformed code means two different problems now report
	/// the same thing in the UI and in bug reports, which is the one guarantee these constants make.
	/// </summary>
	static void DiagnosticCodeTests( Action<string, bool, string> check )
	{
		var codes = DiagnosticCode.All;

		check( "the diagnostic code table is populated", codes.Count > 30, $"{codes.Count} codes" );

		var duplicates = codes.GroupBy( x => x, StringComparer.Ordinal )
			.Where( x => x.Count() > 1 ).Select( x => x.Key ).ToArray();

		check( "no two diagnostic codes collide", duplicates.Length == 0, string.Join( ",", duplicates ) );

		var malformed = codes.Where( x => x is null || x.Length != 6 || !x.StartsWith( "PR", StringComparison.Ordinal ) ||
			!x[2..].All( char.IsDigit ) ).ToArray();

		check( "every diagnostic code is PR####", malformed.Length == 0, string.Join( ",", malformed ) );

		check( "the text tier folded into the code table",
			DiagnosticCode.IsKnown( "PR6001" ) && DiagnosticCode.IsKnown( "PR6011" ), null );

		check( "codes are grouped by tier",
			DiagnosticCode.TierOf( DiagnosticCode.Cycle ) == "Graph" &&
			DiagnosticCode.TierOf( DiagnosticCode.Unbalanced ) == "Text" &&
			DiagnosticCode.TierOf( DiagnosticCode.RoundTripFailed ) == "Document", null );
	}

	/// <summary>
	/// Encoding a literal has to be stable: encode, decode and encode again must produce the same JSON,
	/// for every shader type against every shape of value a property or a hand-edited document can hold.
	/// A value that decodes to something other than what was written is a value the user loses on the
	/// next save, silently.
	/// </summary>
	static void ValueCodecStabilityTests( Action<string, bool, string> check )
	{
		var types = new[]
		{
			ShaderType.Void, ShaderType.Bool, ShaderType.Int, ShaderType.UInt, ShaderType.Half,
			ShaderType.Float, ShaderType.Float2, ShaderType.Float3, ShaderType.Float4,
			ShaderType.Int2, ShaderType.Bool3, ShaderType.Float4x4,
			ShaderType.Texture2D, ShaderType.TextureCube, ShaderType.Sampler
		};

		var values = new object[]
		{
			null, 0, 1, -1, 0.5f, -0.5f, float.MaxValue, float.MinValue, float.NaN,
			float.PositiveInfinity, float.NegativeInfinity, true, false, "text", string.Empty,
			Vector2.One, new Vector3( 1f, 2f, 3f ), new Vector4( 1f, 2f, 3f, 4f ), Color.White,
			int.MaxValue, int.MinValue, 1e30f, 1e-30f
		};

		var unstable = new List<string>();
		var threw = new List<string>();

		foreach ( var type in types )
		{
			foreach ( var value in values )
			{
				try
				{
					var written = ValueCodec.Write( type, ValueCodec.Coerce( value, type ) );
					var again = ValueCodec.Write( type, ValueCodec.Read( type, written ) );

					if ( ( written?.ToJsonString() ?? "null" ) != ( again?.ToJsonString() ?? "null" ) )
					{
						unstable.Add( $"{type} <- {value ?? "null"}: {written?.ToJsonString()} then {again?.ToJsonString()}" );
					}
				}
				catch ( Exception e )
				{
					threw.Add( $"{type} <- {value ?? "null"}: {e.GetType().Name}" );
				}
			}
		}

		check( $"encoding {types.Length * values.Length} type/value combinations never throws",
			threw.Count == 0, First( threw ) );
		check( "and every one of them is a stable encoding", unstable.Count == 0, First( unstable ) );

		// The trap that made every number read as zero: a JsonValue built in memory wraps the exact CLR
		// type it was created from, and asking a JsonValue<int> for a float returns false.
		check( "a number held as an int is read as a number",
			ValueCodec.NumberOf( JsonValue.Create( 7 ) ).Equals( 7f ), null );
		check( "a number held as a double is read as a number",
			ValueCodec.NumberOf( JsonValue.Create( 7.5 ) ).Equals( 7.5f ), null );
		check( "a number held as a long is read as a number",
			ValueCodec.NumberOf( JsonValue.Create( 7L ) ).Equals( 7f ), null );
		check( "an integer literal survives an in-memory round trip",
			ValueCodec.Read( ShaderType.Int, ValueCodec.Write( ShaderType.Int, 7 ) ) is 7, null );
		check( "a document handed over as a live JsonObject keeps its numbers",
			ReadJson( WriteJson( Read( SelfTestDocument, new DiagnosticSink() ) ), new DiagnosticSink() )
				is { } live && live.View.Zoom.Equals( 0.9f ), null );
	}

	/// <summary>
	/// The edge indexes have to agree with the edge list after any sequence of raw mutations. They are
	/// three separate lookup tables maintained by hand, and a stale entry in one of them is a connection
	/// that is drawn but not compiled, or compiled but not drawn.
	/// </summary>
	static void GraphIndexTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;

		var graph = new PrismGraph();
		var random = new Random( 99 );
		var outPort = PortId.Parse( "Out" );
		var inPort = PortId.Parse( "B" );

		for ( int i = 0; i < 8; i++ )
		{
			graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
		}

		string failure = null;

		for ( int step = 0; step < 2000 && failure is null; step++ )
		{
			var nodes = graph.Nodes.ToArray();

			try
			{
				switch ( random.Next( 10 ) )
				{
					case 0:
						graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
						break;

					case 1 when nodes.Length > 2:
						graph.RemoveNode( nodes[random.Next( nodes.Length )].Id );
						break;

					case 2 when nodes.Length > 1:
						graph.Connect( new PortRef( nodes[random.Next( nodes.Length )].Id, outPort ),
							new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) );
						break;

					case 3 when graph.Edges.Count > 0:
						graph.RemoveEdge( graph.Edges[random.Next( graph.Edges.Count )].Id );
						break;

					case 4 when nodes.Length > 0:
						graph.DisconnectPort( new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) );
						break;

					case 5 when graph.Edges.Count > 0:
						graph.ReplaceEdge( graph.Edges[random.Next( graph.Edges.Count )] with
						{
							Fill = (float)random.NextDouble()
						} );
						break;

					case 6 when nodes.Length > 1:
						// Forced in the way a corrupt document would, bypassing every validity check.
						graph.AddEdge( Edge.Between( graph.NewEdgeId(),
							new PortRef( nodes[random.Next( nodes.Length )].Id, outPort ),
							new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) ) );
						break;

					case 7 when nodes.Length > 0:
						if ( nodes[random.Next( nodes.Length )] is PrismSelfTestNode grow )
						{
							grow.ExtraPorts = random.Next( 4 );
							NodeProperties.RebuildPorts( grow );
						}

						break;

					case 8 when nodes.Length > 0:
						if ( nodes[random.Next( nodes.Length )] is PrismSelfTestNode hide )
						{
							hide.HideB = random.Next( 2 ) == 0;
							NodeProperties.RebuildPorts( hide );
						}

						break;

					case 9:
						graph.RepairBrokenEdges();
						break;
				}
			}
			catch ( Exception e )
			{
				failure = $"step {step} threw {e.GetType().Name}: {e.Message}";
				break;
			}

			if ( step % 25 != 0 ) continue;

			failure = IndexProblem( graph ) is { } problem ? $"step {step}: {problem}" : null;
		}

		check( "2000 raw graph mutations keep every edge index consistent", failure is null, failure );

		var text = Write( graph );

		check( "and the mauled document still round-trips",
			text == Write( Read( text, new DiagnosticSink() ) ), null );

		graph.Clear();

		check( "clearing a document empties it",
			graph.Nodes.Count == 0 && graph.Edges.Count == 0 && graph.BrokenEdges.Count == 0, null );
		check( "and leaves nothing behind in the indexes", IndexProblem( graph ) is null, IndexProblem( graph ) );
	}

	/// <summary>The first disagreement between the edge list and the lookup indexes, or null.</summary>
	static string IndexProblem( PrismGraph graph )
	{
		foreach ( var edge in graph.Edges )
		{
			if ( graph.FindEdge( edge.Id ) is null ) return $"{edge.Id} is not findable by id";
			if ( !graph.GetIncomingEdges( edge.ToNode, edge.ToPort ).Any( x => x.Id == edge.Id ) )
				return $"{edge.Id} is missing from the incoming index";
			if ( !graph.GetOutgoingEdges( edge.FromNode, edge.FromPort ).Any( x => x.Id == edge.Id ) )
				return $"{edge.Id} is missing from the outgoing index";
			if ( !graph.EdgesOf( edge.FromNode ).Any( x => x.Id == edge.Id ) )
				return $"{edge.Id} is missing from its source node's index";
			if ( !graph.EdgesOf( edge.ToNode ).Any( x => x.Id == edge.Id ) )
				return $"{edge.Id} is missing from its target node's index";
			if ( graph.FindNode( edge.FromNode ) is null ) return $"{edge.Id} leaves a node that is gone";
			if ( graph.FindNode( edge.ToNode ) is null ) return $"{edge.Id} lands on a node that is gone";
		}

		var live = new HashSet<EdgeId>( graph.Edges.Select( x => x.Id ) );

		foreach ( var node in graph.Nodes )
		{
			if ( !ReferenceEquals( node.Graph, graph ) ) return $"node {node.Id} does not point back at its document";
			if ( graph.FindNode( node.Id ) is null ) return $"node {node.Id} is not findable by id";

			foreach ( var edge in graph.EdgesOf( node.Id ) )
			{
				if ( !live.Contains( edge.Id ) ) return $"stale {edge.Id} still indexed on node {node.Id}";
			}

			foreach ( var port in node.Inputs )
			{
				foreach ( var edge in graph.GetIncomingEdges( node.Id, port.Id ) )
				{
					if ( !live.Contains( edge.Id ) ) return $"stale {edge.Id} still indexed on {node.Id}.{port.Id}";
				}
			}

			foreach ( var port in node.Outputs )
			{
				foreach ( var edge in graph.GetOutgoingEdges( node.Id, port.Id ) )
				{
					if ( !live.Contains( edge.Id ) ) return $"stale {edge.Id} still indexed on {node.Id}.{port.Id}";
				}
			}
		}

		if ( graph.Edges.Select( x => x.Id ).Distinct().Count() != graph.Edges.Count ) return "duplicate edge ids";
		if ( graph.Nodes.Select( x => x.Id ).Distinct().Count() != graph.Nodes.Count ) return "duplicate node ids";

		return null;
	}

	/// <summary>
	/// Every mutation asked to do something impossible has to refuse, say so, and leave no undo step
	/// behind. A refused edit that still records a step is a history the user cannot reason about.
	/// </summary>
	static void MutationGuardTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;

		var graph = new PrismGraph();
		var undo = new PrismUndoStack( graph ) { CoalesceMs = 0 };
		var mutations = new GraphMutations( graph, undo );

		var first = mutations.AddNode( PrismSelfTestNode.TypeId, Vector2.Zero );
		var second = mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( 200f, 0f ) );

		var outPort = PortId.Parse( "Out" );
		var inPort = PortId.Parse( "B" );
		var missing = NodeId.New();
		var steps = undo.Count;
		var refusals = new List<string>();

		void Refuses( string what, bool refused )
		{
			if ( !refused ) refusals.Add( what );
		}

		try
		{
			Refuses( "an unknown node type", mutations.AddNode( "no.such.type", Vector2.Zero ) is null );
			Refuses( "a null node", mutations.AddNode( (PrismNode)null, Vector2.Zero ) is null );
			Refuses( "removing a node that is not there", !mutations.RemoveNode( missing ) );
			Refuses( "removing the default id", !mutations.RemoveNode( default ) );
			Refuses( "moving a node that is not there", !mutations.Move( missing, Vector2.One ) );
			Refuses( "setting a property on a node that is not there",
				!mutations.SetProperty( missing, "Channel", 1 ) );
			Refuses( "setting a property with no name", !mutations.SetProperty( first.Id, null, 1 ) );
			Refuses( "setting a property that does not exist",
				!mutations.SetProperty( first.Id, "NoSuchProperty", 1 ) );
			Refuses( "setting a literal on a port that does not exist",
				!mutations.SetInlineValue( first.Id, PortId.Parse( "Nope" ), 1f ) );
			Refuses( "connecting from a node that is not there",
				mutations.Connect( new PortRef( missing, outPort ), new PortRef( second.Id, inPort ) ) is null );
			Refuses( "connecting to a port that is not there",
				mutations.Connect( new PortRef( first.Id, outPort ),
					new PortRef( second.Id, PortId.Parse( "Nope" ) ) ) is null );
			Refuses( "connecting a node to itself",
				mutations.Connect( new PortRef( first.Id, outPort ), new PortRef( first.Id, inPort ) ) is null );
			Refuses( "connecting an input to an output",
				mutations.Connect( new PortRef( first.Id, inPort ), new PortRef( second.Id, outPort ) ) is null );
			Refuses( "disconnecting a connection that is not there", !mutations.Disconnect( EdgeId.New() ) );
			Refuses( "removing a parameter that is not there", !mutations.RemoveParameter( ParamId.New() ) );
			Refuses( "renaming a parameter that is not there", !mutations.RenameParameter( ParamId.New(), "x" ) );
			Refuses( "editing a group that is not there", !mutations.EditGroup( "nope", x => { } ) );
			Refuses( "editing with no callback", !mutations.EditGroup( "nope", null ) );
			Refuses( "pasting nothing", !mutations.Paste( null, Vector2.Zero ).Ok );
			Refuses( "duplicating nothing", !mutations.Duplicate( Array.Empty<NodeId>(), Vector2.Zero ).Ok );
			Refuses( "copying nothing", mutations.Copy( Array.Empty<NodeId>() ) is null );
			Refuses( "cutting nothing", mutations.Cut( Array.Empty<NodeId>() ) is null );

			check( "every impossible mutation is refused", refusals.Count == 0, string.Join( "; ", refusals ) );
			check( "and none of them recorded an undo step", undo.Count == steps, $"{undo.Count} vs {steps}" );
		}
		catch ( Exception e )
		{
			check( "an impossible mutation never throws", false, e.GetType().Name + ": " + e.Message );
		}

		// A callback that throws must leave the document and the stack exactly as they were.
		var parameter = mutations.AddParameter( "Guarded", ShaderType.Float );
		var before = Write( graph );

		try
		{
			mutations.EditParameter( parameter.Id, x => throw new InvalidOperationException( "deliberate" ) );

			check( "an edit callback that throws leaves the document alone", Write( graph ) == before, null );
			check( "and does not leave the undo stack capturing", !undo.IsCapturing, null );
		}
		catch ( Exception e )
		{
			check( "an edit callback that throws is contained", false, e.GetType().Name );
		}

		// And the whole API has to be inert when it has nothing to work on.
		try
		{
			var inert = new GraphMutations( null );

			inert.AddNode( PrismSelfTestNode.TypeId, Vector2.Zero );
			inert.RemoveNode( missing );
			inert.Connect( new PortRef( missing, outPort ), new PortRef( missing, inPort ) );
			inert.Paste( "x", Vector2.Zero );
			inert.UpdateSettings( x => { } );

			check( "a mutation API with no document is inert rather than fatal", true, null );
		}
		catch ( Exception e )
		{
			check( "a mutation API with no document is inert rather than fatal", false, e.GetType().Name );
		}
	}

	/// <summary>Values injected into every field of the worked example, one at a time.</summary>
	static JsonNode[] Poisons() => new JsonNode[]
	{
		null,
		JsonValue.Create( "" ),
		JsonValue.Create( "garbage" ),
		JsonValue.Create( 0 ),
		JsonValue.Create( -1 ),
		JsonValue.Create( 999999999 ),
		JsonValue.Create( true ),
		JsonValue.Create( 1e30 ),
		JsonValue.Create( -1e30 ),
		JsonValue.Create( "NaN" ),
		JsonValue.Create( "Infinity" ),
		JsonValue.Create( "-Infinity" ),
		new JsonArray(),
		new JsonArray( 1, 2, 3, 4, 5, 6 ),
		new JsonArray( JsonValue.Create( "NaN" ), JsonValue.Create( "Infinity" ) ),
		new JsonObject(),
		new JsonObject { ["nope"] = 1 }
	};

	/// <summary>
	/// Corrupt every field of a saved document in turn — remove it, then replace it with each of
	/// <see cref="Poisons"/> — and require four things of every single result: reading never throws,
	/// writing is deterministic, writing is idempotent across a reparse, and no coordinate comes back
	/// non-finite. That last one matters more than it looks: one NaN position poisons every bounding
	/// box that unions it, so fit-to-view, the minimap and marquee selection all stop working and
	/// nothing says why.
	/// </summary>
	static void CorruptionTests( Action<string, bool, string> check )
	{
		var paths = new List<string>();

		CollectPaths( JsonNode.Parse( SelfTestDocument ), string.Empty, paths, 0 );

		var poisons = Poisons();
		var cases = 0;
		var thrown = new List<string>();
		var unstable = new List<string>();
		var nonIdempotent = new List<string>();
		var nonFinite = new List<string>();

		foreach ( var path in paths )
		{
			for ( int p = -1; p < poisons.Length; p++ )
			{
				var document = JsonNode.Parse( SelfTestDocument ) as JsonObject;
				var label = $"{path} := {( p < 0 ? "<removed>" : poisons[p]?.ToJsonString() ?? "null" )}";

				if ( p < 0 )
				{
					if ( !RemoveAt( document, path ) ) continue;
				}
				else if ( !ReplaceAt( document, path, poisons[p]?.DeepClone() ) )
				{
					continue;
				}

				cases++;

				try
				{
					var graph = Read( document.ToJsonString( PrismJson.Options ), new DiagnosticSink() );

					if ( graph is null ) continue;

					var first = Write( graph );
					var second = Write( graph );

					if ( !string.Equals( first, second, StringComparison.Ordinal ) )
					{
						unstable.Add( label );
						continue;
					}

					var reread = Read( first, new DiagnosticSink() );

					if ( reread is null || !string.Equals( first, Write( reread ), StringComparison.Ordinal ) )
					{
						nonIdempotent.Add( label );
						continue;
					}

					if ( graph.Nodes.Any( x => !IsFinite( x.Position ) ) ||
						graph.Groups.Any( x => !IsFinite( x.Position ) || !IsFinite( x.Size ) ) ||
						graph.Notes.Any( x => !IsFinite( x.Position ) || !IsFinite( x.Size ) ) ||
						graph.View is null || !IsFinite( graph.View.Center ) || !float.IsFinite( graph.View.Zoom ) )
					{
						nonFinite.Add( label );
					}
				}
				catch ( Exception e )
				{
					thrown.Add( $"{label} -> {e.GetType().Name}: {e.Message}" );
				}
			}
		}

		check( $"{cases} field corruptions of the worked example were all survivable",
			thrown.Count == 0, First( thrown ) );
		check( "every corrupted document still wrote deterministically", unstable.Count == 0, First( unstable ) );
		check( "every corrupted document still wrote idempotently", nonIdempotent.Count == 0, First( nonIdempotent ) );
		check( "no corrupted document produced a non-finite coordinate", nonFinite.Count == 0, First( nonFinite ) );
	}

	/// <summary>The document-level failures: not JSON, not a document, or a structurally impossible one.</summary>
	static void MalformedDocumentTests( Action<string, bool, string> check )
	{
		var refused = new[]
		{
			("text that is not JSON", "hello world"),
			("an empty file", ""),
			("a zero byte", "\0"),
			("a truncated document", SelfTestDocument[..400]),
			("an array at the root", "[1,2,3]"),
			("a bare null", "null"),
			("a bare number", "42"),
			("300 levels of nesting", new string( '[', 300 ) + new string( ']', 300 ) )
		};

		foreach ( var (what, text) in refused )
		{
			var sink = new DiagnosticSink();

			try
			{
				check( $"{what} is refused", Read( text, sink ) is null, null );
				check( $"{what} says why", sink.HasErrors, null );
			}
			catch ( Exception e )
			{
				check( $"{what} does not throw", false, e.GetType().Name );
			}
		}

		// A byte-order mark is the one that used to lose the whole document.
		var bom = Read( '' + SelfTestDocument, new DiagnosticSink() );

		check( "a document saved with a byte-order mark still opens", bom is not null, null );
		check( "and comes back whole", bom is not null && bom.Nodes.Count == 9, $"{bom?.Nodes.Count}" );

		check( "a document with Unix line endings opens",
			Read( SelfTestDocument.Replace( "\r\n", "\n" ), new DiagnosticSink() )?.Nodes.Count == 9, null );
		check( "a document with Windows line endings opens",
			Read( SelfTestDocument.Replace( "\r\n", "\n" ).Replace( "\n", "\r\n" ), new DiagnosticSink() )?.Nodes.Count == 9,
			null );

		var survivable = new[]
		{
			("two nodes sharing an id", """
			{ "schema":1, "nodes":[ {"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]},
			  {"id":"a","type":"prism.internal.selftest","v":1,"pos":[9,0]} ] }
			"""),
			("an edge from a node to itself", """
			{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]}],
			  "edges":[{"id":"e","from":{"node":"a","port":"Out"},"to":{"node":"a","port":"B"}}] }
			"""),
			("an edge to a node that does not exist", """
			{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]}],
			  "edges":[{"id":"e","from":{"node":"zz","port":"Out"},"to":{"node":"a","port":"B"}}] }
			"""),
			("an edge to a port that does not exist", """
			{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]},
			  {"id":"b","type":"prism.internal.selftest","v":1,"pos":[9,0]}],
			  "edges":[{"id":"e","from":{"node":"a","port":"Nope"},"to":{"node":"b","port":"B"}}] }
			"""),
			("a cycle written straight into the file", """
			{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]},
			  {"id":"b","type":"prism.internal.selftest","v":1,"pos":[9,0]}],
			  "edges":[{"id":"e1","from":{"node":"a","port":"Out"},"to":{"node":"b","port":"B"}},
			           {"id":"e2","from":{"node":"b","port":"Out"},"to":{"node":"a","port":"B"}}] }
			"""),
			("two connections sharing an id", """
			{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]},
			  {"id":"b","type":"prism.internal.selftest","v":1,"pos":[9,0]}],
			  "edges":[{"id":"e","from":{"node":"a","port":"Out"},"to":{"node":"b","port":"B"}},
			           {"id":"e","from":{"node":"b","port":"Out"},"to":{"node":"a","port":"A"}}] }
			"""),
			("a node whose properties are all the wrong type", """
			{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0],
			  "props":{"Label":[1,2],"Channel":"xyz","Mode":99,"Enabled":"maybe","ExtraPorts":-5}}] }
			"""),
			("a node version two billion behind", """
			{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":-2147483648,"pos":[0,0]}] }
			"""),
			("a schema from the future", """{ "schema":999, "nodes":[], "somethingNew":{"a":1} }"""),
			("a negative schema", """{ "schema":-3, "nodes":[] }"""),

			// What a bad three-way merge produces. A lazily-materialised node tree throws
			// ArgumentException the first time anything reads an object with a repeated key, from
			// inside whichever reader happened to touch it, so this is the one corruption that used
			// to escape as an exception rather than a diagnostic.
			("a top-level key declared twice", """{ "schema":1, "schema":2, "nodes":[] }"""),
			("a node key declared twice", """
			{ "schema":1, "nodes":[{"id":"a","id":"b","type":"prism.internal.selftest","v":1,"pos":[0,0]}] }
			"""),
			("the node list declared twice", """
			{ "schema":1, "nodes":[], "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]}] }
			"""),
			("a property declared twice inside a node", """
			{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0],
			  "props":{"Channel":1,"Channel":9}}] }
			""")
		};

		foreach ( var (what, text) in survivable )
		{
			try
			{
				var clock = System.Diagnostics.Stopwatch.StartNew();
				var graph = Read( text, new DiagnosticSink() );

				check( $"{what} still loads", graph is not null, null );

				if ( graph is null ) continue;

				check( $"{what} loads promptly", clock.ElapsedMilliseconds < 2000, $"{clock.ElapsedMilliseconds} ms" );

				var first = Write( graph );

				check( $"{what} round-trips idempotently",
					first == Write( Read( first, new DiagnosticSink() ) ), null );

				GraphQueries.TryFindCycle( graph, out _ );
				GraphQueries.TopologicalOrder( graph );
				GraphQueries.Validate( graph );
			}
			catch ( Exception e )
			{
				check( $"{what} does not throw", false, e.GetType().Name + ": " + e.Message );
			}
		}
	}

	/// <summary>
	/// <see cref="Restore"/> has to be observationally identical to reading the same text into a fresh
	/// document. Undo is only trustworthy while that holds: a restore that leaves anything of the
	/// previous state behind is a document that silently disagrees with its own history.
	/// </summary>
	static void RestoreEquivalenceTests( Action<string, bool, string> check )
	{
		var documents = new[]
		{
			SelfTestDocument,
			"""{ "schema":1, "nodes":[] }""",
			"""{ "schema":1, "id":"aaa","nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[3,4]}] }""",
			"""{ "schema":1, "id":"bbb","nodes":[{"id":"a","type":"nope","v":1,"pos":[3,4],"props":{"Q":1}}] }""",
			"""
			{ "schema":1, "id":"ccc", "kind":"subgraph",
			  "meta":{"title":"T"},
			  "parameters":[{"id":"p","name":"A","type":"float3","default":[1,2,3]}],
			  "keywords":[{"id":"k","name":"F_X","kind":"Static","values":["a","b"],"default":1}],
			  "groups":[{"id":"g","title":"T","rect":[1,2,3,4],"color":"Red"}],
			  "notes":[{"id":"n","pos":[1,2],"size":[3,4],"color":"Blue","text":"hi"}],
			  "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[3,4],"props":{"ExtraPorts":2}},
			           {"id":"b","type":"prism.internal.selftest","v":1,"pos":[9,4]}],
			  "edges":[{"id":"e","from":{"node":"a","port":"Out"},"to":{"node":"b","port":"B"},"via":[[1,2]],"fill":1}],
			  "view":{"center":[5,6],"zoom":0.5} }
			"""
		};

		var mismatches = new List<string>();

		for ( int i = 0; i < documents.Length; i++ )
		{
			for ( int j = 0; j < documents.Length; j++ )
			{
				var live = Read( documents[i], new DiagnosticSink() );
				var fresh = Read( documents[j], new DiagnosticSink() );

				if ( live is null || fresh is null ) continue;

				// A document that declares no id gets a fresh one on every read, and Restore is defined to
				// keep the live one in that case, so identity is normalised away before comparing.
				fresh.DocumentId = "-";

				var expected = Write( fresh );

				Restore( live, documents[j], new DiagnosticSink() );

				live.DocumentId = "-";

				if ( !string.Equals( expected, Write( live ), StringComparison.Ordinal ) )
				{
					mismatches.Add( $"{i}->{j}: {Diff( expected, Write( live ) )}" );
				}
			}
		}

		check( $"restoring any of {documents.Length} documents over any other matches a fresh read",
			mismatches.Count == 0, First( mismatches ) );

		// Restoring from garbage must leave a usable document, and the real snapshot must recover it.
		var target = Read( SelfTestDocument, new DiagnosticSink() );
		var original = Write( target );
		var threw = false;

		foreach ( var junk in new[] { null, "", "not json", "[]", "42", "{}", "{\"nodes\":5}" } )
		{
			try
			{
				Restore( target, junk, new DiagnosticSink() );
			}
			catch
			{
				threw = true;
			}
		}

		check( "restoring from garbage never throws", !threw, null );
		check( "and the real snapshot still recovers everything",
			Restore( target, original, new DiagnosticSink() ) && Write( target ) == original,
			Diff( original, Write( target ) ) );
	}

	/// <summary>
	/// The acceptance gate for undo: five hundred randomised edits of every kind, undone all the way,
	/// must leave the document byte-identical — and redone all the way, byte-identical to where it
	/// ended. Then the same again after a random walk through the history panel.
	/// </summary>
	static void UndoStormTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;

		var graph = new PrismGraph();
		var undo = new PrismUndoStack( graph ) { CoalesceMs = 0, Capacity = 4096 };
		var mutations = new GraphMutations( graph, undo );

		for ( int i = 0; i < 6; i++ )
		{
			mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( i * 160f, 0f ) );
		}

		mutations.AddParameter( "Roughness", ShaderType.Float );
		mutations.AddKeyword( "F_STORM", ComboKind.Feature );
		undo.Clear();

		var start = Write( graph );
		var random = new Random( 20260811 );
		var outPort = PortId.Parse( "Out" );
		var inPort = PortId.Parse( "B" );
		var clipboard = (string)null;
		var failure = (string)null;

		for ( int step = 0; step < 500 && failure is null; step++ )
		{
			var nodes = graph.Nodes.ToArray();

			try
			{
				switch ( random.Next( 14 ) )
				{
					case 0:
						mutations.AddNode( PrismSelfTestNode.TypeId,
							new Vector2( random.Next( -900, 900 ), random.Next( -900, 900 ) ) );
						break;

					case 1 when nodes.Length > 2:
						mutations.RemoveNode( nodes[random.Next( nodes.Length )].Id );
						break;

					case 2 when nodes.Length > 0:
						mutations.Move( nodes[random.Next( nodes.Length )].Id,
							new Vector2( random.Next( -900, 900 ), random.Next( -900, 900 ) ) );
						break;

					case 3 when nodes.Length > 1:
						mutations.Connect( new PortRef( nodes[random.Next( nodes.Length )].Id, outPort ),
							new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) );
						break;

					case 4 when graph.Edges.Count > 0:
						mutations.Disconnect( graph.Edges[random.Next( graph.Edges.Count )].Id );
						break;

					case 5 when nodes.Length > 0:
						mutations.SetProperty( nodes[random.Next( nodes.Length )].Id, "Channel", random.Next( 32 ) );
						break;

					case 6 when nodes.Length > 0:
						mutations.SetInlineValue( nodes[random.Next( nodes.Length )].Id, PortId.Parse( "A" ),
							(float)random.NextDouble() );
						break;

					case 7:
						mutations.AddParameter( "P" + random.Next( 5 ), ShaderType.Float3 );
						break;

					case 8 when graph.Parameters.Count > 1:
						mutations.RemoveParameter( graph.Parameters[random.Next( graph.Parameters.Count )].Id );
						break;

					case 9:
						mutations.AddGroup( new GraphGroup
						{
							Id = Ids.NewShortId(),
							Title = "Group " + step,
							Position = new Vector2( random.Next( 400 ), random.Next( 400 ) ),
							Size = new Vector2( 320f, 200f )
						} );
						break;

					case 10 when nodes.Length > 0:
						clipboard = mutations.Copy( nodes.Take( 2 ).Select( x => x.Id ) );
						break;

					case 11 when clipboard is not null:
						mutations.Paste( clipboard, new Vector2( random.Next( 400 ), random.Next( 400 ) ) );
						break;

					case 12 when nodes.Length > 0:
						mutations.SetProperty( nodes[random.Next( nodes.Length )].Id, "ExtraPorts", random.Next( 4 ) );
						break;

					case 13 when nodes.Length > 0:
						mutations.SetFlag( nodes[random.Next( nodes.Length )].Id, NodeFlags.Preview,
							random.Next( 2 ) == 0 );
						break;
				}
			}
			catch ( Exception e )
			{
				failure = $"step {step}: {e.GetType().Name}: {e.Message}";
			}
		}

		check( "500 randomised edits of every kind ran without throwing", failure is null, failure );

		if ( failure is not null ) return;

		var end = Write( graph );

		check( "the storm changed the document", start != end, null );

		var guard = 0;

		while ( undo.CanUndo && guard++ < 5000 ) undo.Undo();

		check( "undoing 500 edits restores the original byte for byte", Write( graph ) == start,
			Diff( start, Write( graph ) ) );

		guard = 0;

		while ( undo.CanRedo && guard++ < 5000 ) undo.Redo();

		check( "redoing 500 edits restores the final document byte for byte", Write( graph ) == end,
			Diff( end, Write( graph ) ) );

		var walk = new Random( 7 );

		for ( int i = 0; i < 200; i++ )
		{
			undo.JumpTo( walk.Next( -5, undo.Count + 5 ) );
		}

		check( "a random walk through the history panel is still exact at the bottom",
			undo.JumpTo( 0 ) && Write( graph ) == start, Diff( start, Write( graph ) ) );
		check( "and at the top", undo.JumpTo( undo.Count ) && Write( graph ) == end, null );

		undo.Commit();
		undo.Commit();

		check( "stray commits leave the stack usable", !undo.IsCapturing && undo.CanUndo, null );

		using ( undo.Scope( "unbalanced" ) )
		{
			undo.Push( "leaked" );
		}

		check( "an unbalanced push is repaired rather than poisoning the stack", !undo.IsCapturing, null );
	}

	/// <summary>
	/// Deep and wide. The numbers are deliberately past anything a person would author: a traversal
	/// that recurses instead of iterating dies at a few thousand, and it dies by taking the editor with
	/// it rather than by reporting anything.
	/// </summary>
	static void ScaleTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;

		var outPort = PortId.Parse( "Out" );
		var inPort = PortId.Parse( "B" );

		var wide = new PrismGraph();

		for ( int i = 0; i < 5000; i++ )
		{
			var node = NodeRegistry.Create( PrismSelfTestNode.TypeId );
			node.Position = new Vector2( i % 100 * 200f, i / 100 * 200f );
			wide.AddNode( node );
		}

		check( "5000 nodes can be added", wide.Nodes.Count == 5000, $"{wide.Nodes.Count}" );

		var text = Write( wide );

		check( "5000 nodes round-trip", Read( text, new DiagnosticSink() )?.Nodes.Count == 5000, null );

		var fan = new PrismGraph();
		var source = fan.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

		for ( int i = 0; i < 200; i++ )
		{
			var node = fan.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
			fan.Connect( new PortRef( source.Id, outPort ), new PortRef( node.Id, inPort ) );
		}

		check( "one output drives 200 connections", fan.Edges.Count == 200, $"{fan.Edges.Count}" );
		check( "200 outgoing connections round-trip",
			Read( Write( fan ), new DiagnosticSink() )?.Edges.Count == 200, null );

		var chain = new PrismGraph();
		PrismNode previous = null;

		for ( int i = 0; i < 5000; i++ )
		{
			var node = chain.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

			if ( previous is not null )
			{
				chain.AddEdge( Edge.Between( chain.NewEdgeId(), new PortRef( previous.Id, outPort ),
					new PortRef( node.Id, inPort ) ) );
			}

			previous = node;
		}

		try
		{
			check( "a 5000-deep chain is acyclic", !GraphQueries.TryFindCycle( chain, out _ ), null );
			check( "a 5000-deep chain sorts topologically",
				GraphQueries.TopologicalOrder( chain ).Count == 5000, null );
			check( "a 5000-deep dependency subtree resolves",
				GraphQueries.DependencySubtree( chain, previous.Id ).Count == 5000, null );

			chain.AddEdge( Edge.Between( chain.NewEdgeId(), new PortRef( previous.Id, outPort ),
				new PortRef( chain.Nodes[0].Id, inPort ) ) );

			check( "a 5000-deep cycle is detected rather than overflowing the stack",
				GraphQueries.TryFindCycle( chain, out var cycle ) && cycle.Count == 5001, null );

			var described = GraphQueries.DescribeCycle( chain, GraphQueries.FindCycles( chain, 1 ).FirstOrDefault() );

			check( "a huge cycle is described in a length a human and a tooltip can hold",
				described.Length is > 0 and < 4096, $"{described.Length} chars" );
		}
		catch ( Exception e )
		{
			check( "5000-deep traversals do not throw", false, e.GetType().Name + ": " + e.Message );
		}

		// A connection with more routing waypoints than anyone could have placed by hand — one drag
		// against a runaway auto-router is all it takes, and every one of them is read, written and
		// hit-tested.
		try
		{
			var via = new JsonArray();

			for ( int i = 0; i < 10000; i++ ) via.Add( new JsonArray( i, i ) );

			var document = new JsonObject
			{
				[KeySchema] = 1,
				["nodes"] = new JsonArray(
					new JsonObject
					{
						[KeyId] = "a", ["type"] = PrismSelfTestNode.TypeId, ["v"] = 1,
						["pos"] = new JsonArray( 0, 0 )
					},
					new JsonObject
					{
						[KeyId] = "b", ["type"] = PrismSelfTestNode.TypeId, ["v"] = 1,
						["pos"] = new JsonArray( 256, 0 )
					} ),
				["edges"] = new JsonArray( new JsonObject
				{
					[KeyId] = "e",
					["from"] = new JsonObject { ["node"] = "a", ["port"] = "Out" },
					["to"] = new JsonObject { ["node"] = "b", ["port"] = "B" },
					["via"] = via
				} )
			};

			var loaded = Read( document.ToJsonString( PrismJson.Options ), new DiagnosticSink() );

			check( "a connection with ten thousand waypoints loads",
				loaded is not null && loaded.Edges.Count == 1, $"{loaded?.Edges.Count}" );
			check( "and keeps every one of them",
				loaded?.Edges.FirstOrDefault()?.Via?.Length == 10000,
				$"{loaded?.Edges.FirstOrDefault()?.Via?.Length}" );
			check( "and round-trips", Write( loaded ) == Write( Read( Write( loaded ), new DiagnosticSink() ) ),
				null );
		}
		catch ( Exception e )
		{
			check( "a huge waypoint list does not throw", false, $"{e.GetType().Name}: {e.Message}" );
		}

		// Nesting deep enough to blow a recursive walker, in the one place a document nests freely.
		var nested = new JsonObject();
		var cursor = nested;

		for ( int i = 0; i < 200; i++ )
		{
			var next = new JsonObject();
			cursor["x"] = next;
			cursor = next;
		}

		try
		{
			var deep = new JsonObject { [KeySchema] = 1, ["nodes"] = new JsonArray(), ["x"] = nested };
			var json = deep.ToJsonString( PrismJson.Options );
			var loaded = Read( json, new DiagnosticSink() );

			check( "a 200-level forward-compatibility bag survives a load", loaded is not null, null );
			check( "and can be written back out", !string.IsNullOrEmpty( Write( loaded ) ), null );
		}
		catch ( Exception e )
		{
			check( "a deeply nested forward-compatibility bag does not throw", false, e.GetType().Name );
		}
	}

	/// <summary>
	/// Everything that can go missing between one session and the next: a node type that left the
	/// assembly, a parameter deleted out from under the nodes that referenced it, a clipboard payload
	/// from somewhere else, a destination that will not accept a write, and the scratch workspace.
	/// </summary>
	static void RecoveryTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;

		// ---- a node type that vanished between save and load
		var graph = new PrismGraph();
		var a = graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
		var b = graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

		graph.Connect( new PortRef( a.Id, PortId.Parse( "Out" ) ), new PortRef( b.Id, PortId.Parse( "B" ) ) );

		var vanished = Write( graph ).Replace( PrismSelfTestNode.TypeId, "vanished.node.type" );
		var sink = new DiagnosticSink();
		var loaded = Read( vanished, sink );

		check( "a document whose node type left the assembly still loads",
			loaded is not null && loaded.Nodes.Count == 2, null );
		check( "every node of the missing type became a placeholder",
			loaded.Nodes.All( x => x is UnknownNode ), null );
		check( "the missing type was reported", sink.Count > 0, null );
		check( "the connection between two placeholders survived",
			loaded.Edges.Count + loaded.BrokenEdges.Count == 1,
			$"{loaded.Edges.Count} live, {loaded.BrokenEdges.Count} broken" );
		check( "and the document re-saves byte-identically", Write( loaded ) == vanished,
			Diff( vanished, Write( loaded ) ) );

		var reinstalled = Read( vanished.Replace( "vanished.node.type", PrismSelfTestNode.TypeId ),
			new DiagnosticSink() );

		check( "installing the missing addon again brings the real nodes back",
			reinstalled is not null && reinstalled.Nodes.All( x => x is PrismSelfTestNode ), null );
		check( "and brings the connection back live", reinstalled.Edges.Count == 1, null );

		// ---- a parameter deleted while a node still references it
		var referencing = new PrismGraph();
		var parameter = referencing.AddParameter( new Parameter( "Rough", ShaderType.Float ) );
		var user = referencing.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

		NodeProperties.Set( user, "Label", parameter.Id.Value );
		referencing.RemoveParameter( parameter.Id );

		try
		{
			var written = Write( referencing );

			check( "a dangling parameter reference round-trips",
				written == Write( Read( written, new DiagnosticSink() ) ), null );
			check( "and validation reports rather than throws",
				GraphQueries.Validate( referencing ) is not null, null );
		}
		catch ( Exception e )
		{
			check( "a dangling parameter reference does not throw", false, e.GetType().Name );
		}

		// ---- foreign and truncated clipboard payloads
		var host = Read( SelfTestDocument, new DiagnosticSink() );
		var untouched = Write( host );
		var accepted = 0;
		var clipThrew = (string)null;

		foreach ( var junk in new[] { null, "", "   ", "hello", "prism.graph:", "prism.graph:!!!!",
			"prism.graph:aGVsbG8=", "other.graph:abc" } )
		{
			try
			{
				var result = ClipboardCodec.Paste( host, junk, Vector2.Zero, new DiagnosticSink() );

				if ( result is { Ok: true, Nodes.Count: > 0 } ) accepted++;
			}
			catch ( Exception e )
			{
				clipThrew = $"{junk}: {e.GetType().Name}";
			}
		}

		check( "a foreign clipboard payload never throws", clipThrew is null, clipThrew );
		check( "and is never pasted", accepted == 0, $"{accepted} accepted" );
		check( "a refused paste leaves the document untouched", Write( host ) == untouched, null );

		var payload = ClipboardCodec.Encode( host, host.Nodes.Take( 3 ).ToArray() );
		var truncationThrew = (string)null;

		for ( int cut = 1; cut < payload.Length; cut += Math.Max( 1, payload.Length / 25 ) )
		{
			try
			{
				ClipboardCodec.Paste( host, payload[..cut], Vector2.Zero, new DiagnosticSink() );
			}
			catch ( Exception e )
			{
				truncationThrew = $"cut at {cut}: {e.GetType().Name}";
				break;
			}
		}

		check( "a truncated clipboard payload never throws", truncationThrew is null, truncationThrew );

		// The clipboard is the only input to the editor that comes from outside it entirely, so a payload
		// that expands to more memory than the machine has must be refused rather than decompressed.
		try
		{
			var bomb = new System.IO.MemoryStream();

			using ( var zip = new System.IO.Compression.GZipStream( bomb,
				System.IO.Compression.CompressionMode.Compress, true ) )
			{
				var chunk = new byte[1024 * 1024];

				for ( int i = 0; i < ClipboardCodec.MaxDecodedBytes / chunk.Length + 8; i++ )
				{
					zip.Write( chunk, 0, chunk.Length );
				}
			}

			var clock = System.Diagnostics.Stopwatch.StartNew();
			var expanded = ClipboardCodec.TryDecode(
				ClipboardCodec.Prefix + System.Convert.ToBase64String( bomb.ToArray() ), out _ );

			check( "a clipboard payload that expands past the cap is refused", !expanded, null );
			check( "and is refused promptly", clock.ElapsedMilliseconds < 5000, $"{clock.ElapsedMilliseconds} ms" );
		}
		catch ( Exception e )
		{
			check( "a decompression bomb never throws", false, e.GetType().Name );
		}

		// ---- saving somewhere that will not have it
		var saveThrew = (string)null;
		var accepting = new List<string>();

		foreach ( var path in new[] { null, "", "   ", Path.GetTempPath() } )
		{
			try
			{
				if ( Save( host, path, new DiagnosticSink() ) ) accepting.Add( path ?? "null" );
			}
			catch ( Exception e )
			{
				saveThrew = $"{path}: {e.GetType().Name}";
			}
		}

		check( "saving to an impossible path never throws", saveThrew is null, saveThrew );
		check( "and never claims to have succeeded", accepting.Count == 0, string.Join( ",", accepting ) );

		// ---- the scratch workspace, which is where a killed compile leaves its litter
		try
		{
			var workspace = new Toolchain.TempWorkspace( "self/../../test session" );

			check( "a hostile scratch session id cannot escape its folder",
				!workspace.SessionId.Contains( ".." ) && !workspace.SessionId.Contains( '/' ), workspace.SessionId );
			check( "a hostile scratch file name cannot escape its folder",
				!workspace.Relative( "../../boom.txt" ).Contains( ".." ), workspace.Relative( "../../boom.txt" ) );

			workspace.SweepPending();
			workspace.Sweep();
			workspace.Dispose();
			workspace.Dispose();

			check( "a scratch workspace can be disposed twice", true, null );
		}
		catch ( Exception e )
		{
			check( "the scratch workspace never throws", false, e.GetType().Name + ": " + e.Message );
		}

		// ---- the compile service, cancelled and disposed out from under itself
		try
		{
			var service = new Toolchain.ShaderCompileService( "prismselftest" );

			check( "a fresh compile service is not compiling", !service.IsCompiling, null );

			service.Cancel();
			service.Cancel();
			service.ForceRegenerate = true;

			check( "the force flag latches", service.ForceRegenerate, null );

			// Kill a compile mid-flight, over and over. Every one of these is cancelled before it can
			// reach the engine, which is the path that used to be able to leave the flag set and a
			// half-written shader behind for the next compile to trip over.
			var killed = new PrismGraph();
			killed.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

			for ( int i = 0; i < 50; i++ )
			{
				using var cancelled = new CancellationTokenSource();

				cancelled.Cancel();

				var task = service.RequestNow( killed, Compiler.CompileMode.Preview, cancelled.Token );

				PrismLog.Guard( "Awaiting a cancelled compile", () => task.Wait( 2000 ) );
			}

			check( "fifty compiles killed in flight leave nothing running", !service.IsCompiling, null );
			check( "and leave no orphan files in the scratch folder",
				service.Workspace.Files.Count == 0, string.Join( ",", service.Workspace.Files ) );

			service.Dispose();
			service.Dispose();
			service.Request( new PrismGraph() );
			service.Cancel();

			check( "a disposed compile service is inert and not stuck compiling", !service.IsCompiling, null );

			Toolchain.ShaderCompileService.CancelAll();

			check( "cancelling every compile in the editor is safe with none running", true, null );
		}
		catch ( Exception e )
		{
			check( "the compile service never throws", false, e.GetType().Name + ": " + e.Message );
		}

		// ---- hotload, over and over
		try
		{
			var rebuilt = true;

			for ( int i = 0; i < 10 && rebuilt; i++ )
			{
				PrismHotload.FlushAll();
				NodeRegistry.EnsureBuilt();

				var probe = NodeRegistry.Create( PrismSelfTestNode.TypeId );

				rebuilt = NodeRegistry.Count > 0 && probe is not null && probe.Inputs.Count > 0 &&
					Read( Write( new PrismGraph() ), new DiagnosticSink() ) is not null;
			}

			check( "ten hotload flushes rebuild every registry", rebuilt, null );

			var before = NodeDescriptors.Describe( typeof( PrismSelfTestNode ) );

			// Both migration registries hold delegates compiled into the outgoing assembly, and the
			// document-level one runs on the path that rewrites somebody's file.
			SchemaMigrations.Register( 0, "self-test", document => document );
			NodeMigrations.Register( PrismSelfTestNode.TypeId, 0, "self-test", node => node );

			PrismHotload.FlushAll();

			check( "a flush really does drop the descriptor cache",
				!ReferenceEquals( before, NodeDescriptors.Describe( typeof( PrismSelfTestNode ) ) ), null );
			check( "a flush drops every registered document upgrader",
				SchemaMigrations.Registered.Count == 0, $"{SchemaMigrations.Registered.Count} left" );
			check( "and every registered node upgrader",
				NodeMigrations.Registered.Count == 0, $"{NodeMigrations.Registered.Count} left" );

			// The text editor's word tables and lexers are behind the same umbrella flush, and they hold
			// delegates and lazies belonging to whichever assembly built them.
			Editor.Prism.Text.TextCaches.Flush();

			check( "the text editor caches rebuild after being dropped",
				Editor.Prism.Text.LanguageDb.IntrinsicDb.Names.Count > 0 &&
				Editor.Prism.Text.Lexer.Lexers.For( "hlsl" ) is not null, null );

			// Leave the editor as we found it: everything above deliberately emptied the caches.
			NodeRegistry.EnsureBuilt();
		}
		catch ( Exception e )
		{
			check( "hotload recovery never throws", false, e.GetType().Name + ": " + e.Message );
		}
	}

	/// <summary>
	/// A port declares a shader type; its <c>[InlineValue]</c> property declares a CLR type; the two do
	/// not have to be the same shape. A <c>float3</c> port whose literal lives in a <see cref="Color"/>
	/// is the commonest spelling in the node library.
	/// <para>
	/// Reading a document hands the literal back in the shape the <em>port</em> implies, so every load
	/// crosses that boundary. Getting it wrong is invisible in the worst way: the literal is written to
	/// disk perfectly and dropped on the way back in, and the user's colour is white again the next time
	/// they open the graph, with nothing said anywhere.
	/// </para>
	/// </summary>
	static void LiteralShapeTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Create( PrismSelfTestNode.TypeId ) is not PrismSelfTestNode probe ) return;

		var graph = new PrismGraph();

		graph.AddNode( probe );

		var port = probe.FindInput( PortId.Parse( "C" ) );
		var colour = new Color( 0.25f, 0.5f, 0.75f, 1f );

		NodeProperties.SetInline( probe, port, colour );

		check( "a colour literal reaches the property behind a float3 port",
			probe.DefaultC.r.Equals( 0.25f ) && probe.DefaultC.b.Equals( 0.75f ), $"{probe.DefaultC}" );

		var text = Write( graph );
		var loaded = Read( text, new DiagnosticSink() );
		var back = loaded?.Nodes.FirstOrDefault() as PrismSelfTestNode;

		check( "and comes back after a save and reload",
			back is not null && back.DefaultC.r.Equals( 0.25f ) && back.DefaultC.g.Equals( 0.5f ) &&
			back.DefaultC.b.Equals( 0.75f ), $"{back?.DefaultC}" );

		check( "and the document is idempotent across that reparse",
			loaded is not null && Write( loaded ) == text, Diff( text, Write( loaded ) ) );

		// The conversion matrix itself, in both directions and every width.
		NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultC ), new Vector3( 0.1f, 0.2f, 0.3f ) );

		check( "a vector assigned to a colour property pads alpha opaque",
			probe.DefaultC.b.Equals( 0.3f ) && probe.DefaultC.a.Equals( 1f ), $"{probe.DefaultC}" );

		NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultB ), new Color( 0.4f, 0.5f, 0.6f, 0.7f ) );

		check( "a colour assigned to a vector property truncates",
			probe.DefaultB == new Vector3( 0.4f, 0.5f, 0.6f ), $"{probe.DefaultB}" );

		NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultB ), 0.8f );

		check( "a scalar assigned to a vector property splats",
			probe.DefaultB == new Vector3( 0.8f, 0.8f, 0.8f ), $"{probe.DefaultB}" );

		NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultA ), new Vector3( 0.9f, 0f, 0f ) );

		check( "a vector assigned to a scalar property keeps its first component",
			probe.DefaultA.Equals( 0.9f ), $"{probe.DefaultA}" );

		NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultC ), "0.1,0.2,0.3,0.4" );

		check( "an engine-formatted colour string assigned to a colour property parses",
			probe.DefaultC.a.Equals( 0.4f ), $"{probe.DefaultC}" );
	}

	/// <summary>
	/// The same guarantee across the whole node library, which is where the shapes actually vary: one
	/// node of every registered type, a distinctive literal in every inline-bound input, one save, one
	/// load, and every literal has to still be there.
	/// </summary>
	static void LibraryLiteralTests( Action<string, bool, string> check )
	{
		NodeRegistry.EnsureBuilt();

		var graph = new PrismGraph();
		var expected = new List<(NodeId Node, PortId Port, object Value)>();
		var column = 0f;

		object Literal( ShaderType type )
		{
			if ( type.IsBoolean && type.IsScalar ) return true;
			if ( type.IsScalar && type.IsIntegral ) return 3;

			return type.Components switch
			{
				2 => new Vector2( 0.125f, 0.25f ),
				3 => new Vector3( 0.125f, 0.25f, 0.375f ),
				4 => new Vector4( 0.125f, 0.25f, 0.375f, 0.5f ),
				_ => 0.625f
			};
		}

		foreach ( var descriptor in NodeRegistry.All.ToList() )
		{
			var node = PrismLog.Guard<PrismNode>( $"Creating '{descriptor.Id}'",
				() => NodeRegistry.Create( descriptor.Id ) );

			if ( node is null ) continue;

			node.Position = new Vector2( column += 256f, 0f );
			graph.AddNode( node );

			foreach ( var input in node.Inputs.ToList() )
			{
				if ( string.IsNullOrEmpty( input.Def?.InlineValueProperty ) ) continue;

				var type = input.Def.FixedType;

				// Objects carry a path rather than a number, and a matrix literal has no inline editor.
				if ( type.IsObject || type.IsStruct || type.IsMatrix || type.IsVoid ) continue;
				if ( input.Def.IsGeneric ) continue;

				PrismLog.Guard( $"Setting a literal on '{descriptor.Id}.{input.Id}'",
					() => NodeProperties.SetInline( node, input, Literal( type ) ) );

				expected.Add( (node.Id, input.Id, NodeProperties.GetInline( node, input )) );
			}
		}

		check( $"{graph.Nodes.Count} node types carry {expected.Count} inline literals between them",
			expected.Count > 0, null );

		var text = Write( graph );
		var loaded = Read( text, new DiagnosticSink() );

		check( "a document holding one of every node type reloads", loaded is not null, null );

		if ( loaded is null ) return;

		var lost = new List<string>();

		foreach ( var (nodeId, portId, value) in expected )
		{
			var node = loaded.FindNode( nodeId );
			var port = node?.FindInput( portId );
			var actual = port is null ? null : NodeProperties.GetInline( node, port );

			if ( ValueCodec.Equal( value, actual ) ) continue;

			lost.Add( $"{node?.Descriptor?.Id}.{portId} wrote {ValueCodec.Describe( value )} " +
				$"read {ValueCodec.Describe( actual )}" );
		}

		check( "every inline literal in the library survives a save and reload", lost.Count == 0, First( lost ) );
		check( "and the whole library document is idempotent across a reparse", Write( loaded ) == text,
			Diff( text, Write( loaded ) ) );
	}

	/// <summary>
	/// Two combos that lower to one name is a block-header parse failure — and that failure arrives with
	/// an empty program list and no diagnostic at all, so it has to be impossible rather than reported.
	/// Blank names matter for a second reason: a name the reader substitutes and the writer does not is
	/// a document that changes every time it is opened.
	/// </summary>
	static void KeywordNameTests( Action<string, bool, string> check )
	{
		var graph = new PrismGraph();
		var undo = new PrismUndoStack( graph ) { CoalesceMs = 0 };
		var mutations = new GraphMutations( graph, undo );

		var nameless = mutations.AddKeyword( (string)null, ComboKind.Feature );

		check( "a keyword added with no name is given one", !string.IsNullOrWhiteSpace( nameless?.Name ),
			nameless?.Name );

		var blank = Write( graph );

		check( "and its document is idempotent across a reparse",
			Write( Read( blank, new DiagnosticSink() ) ) == blank,
			Diff( blank, Write( Read( blank, new DiagnosticSink() ) ) ) );

		mutations.AddKeyword( "F_PUDDLES", ComboKind.Feature );
		mutations.AddKeyword( "f puddles", ComboKind.Feature );
		mutations.AddKeyword( "PUDDLES", ComboKind.Feature );

		bool Distinct( PrismGraph g ) =>
			g.Keywords.Select( x => x.NormalizedName ).Distinct( StringComparer.OrdinalIgnoreCase ).Count() ==
			g.Keywords.Count;

		check( "three spellings of one combo name become three combos", Distinct( graph ),
			string.Join( ",", graph.Keywords.Select( x => x.NormalizedName ) ) );

		var statik = mutations.AddKeyword( "PUDDLES", ComboKind.Static );

		check( "a static combo may reuse the authored name of a feature",
			statik?.NormalizedName == "S_PUDDLES", statik?.NormalizedName );

		mutations.EditKeyword( graph.Keywords[0].Id, x => x.Name = "F_PUDDLES" );

		check( "renaming a keyword onto another one is repaired", Distinct( graph ),
			string.Join( ",", graph.Keywords.Select( x => x.NormalizedName ) ) );

		var many = Write( graph );

		check( "a document of near-colliding keywords round-trips",
			Write( Read( many, new DiagnosticSink() ) ) == many,
			Diff( many, Write( Read( many, new DiagnosticSink() ) ) ) );

		var guard = 0;

		while ( undo.CanUndo && guard++ < 500 ) undo.Undo();

		check( "and undoes back to a document with no keywords", graph.Keywords.Count == 0,
			$"{graph.Keywords.Count}" );

		// A document that declares the same combo twice by hand.
		var duplicated = Read( """
		{ "schema":1, "keywords":[ {"id":"k1","name":"F_X","kind":"Feature","values":["Off","On"]},
		                           {"id":"k2","name":"F_X","kind":"Feature","values":["Off","On"]} ] }
		""", new DiagnosticSink() );

		check( "a document declaring one combo twice loads with both kept",
			duplicated is not null && duplicated.Keywords.Count == 2, $"{duplicated?.Keywords.Count}" );
		check( "and separated", duplicated is not null && Distinct( duplicated ),
			string.Join( ",", duplicated?.Keywords.Select( x => x.NormalizedName ) ?? Array.Empty<string>() ) );

		var repaired = Write( duplicated );

		check( "and the repair is stable", Write( Read( repaired, new DiagnosticSink() ) ) == repaired,
			Diff( repaired, Write( Read( repaired, new DiagnosticSink() ) ) ) );
	}

	/// <summary>
	/// Keys the storm invents, chosen to shadow the ones every reader looks for.
	/// </summary>
	static readonly string[] s_stormKeys =
	{
		"x", "id", "schema", "kind", "nodes", "edges", "meta", "props", "inline", "flags",
		"pos", "size", "type", "v", "from", "to", "via", "name", "values", "somethingNew"
	};

	/// <summary>
	/// <see cref="CorruptionTests"/> poisons one field at a time, which is the shape of a bad migration.
	/// A file damaged by a crash, a bad merge or a text editor is damaged in several places at once, and
	/// combinations reach states a single edit cannot.
	/// </summary>
	static void DocumentStormTests( Action<string, bool, string> check )
	{
		var poisons = Poisons();
		var random = new Random( 20260812 );
		var thrown = new List<string>();
		var unstable = new List<string>();
		var nonIdempotent = new List<string>();
		var queried = new List<string>();
		var cases = 0;

		for ( int iteration = 0; iteration < 400; iteration++ )
		{
			var document = JsonNode.Parse( SelfTestDocument ) as JsonObject;
			var labels = new List<string>();

			for ( int edit = 0; edit < 1 + random.Next( 4 ); edit++ )
			{
				var paths = new List<string>();

				CollectPaths( document, string.Empty, paths, 0 );

				if ( paths.Count == 0 ) break;

				var path = paths[random.Next( paths.Count )];
				var poison = random.Next( poisons.Length + 2 );

				if ( poison == poisons.Length )
				{
					if ( RemoveAt( document, path ) ) labels.Add( $"{path} := <removed>" );
					continue;
				}

				// Not only damaged fields: a key that should not be there. A key a future schema
				// invents belongs in an x bag, and one that shadows a key the reader knows has to be
				// survivable wherever it lands.
				if ( poison == poisons.Length + 1 )
				{
					var (parent, _, _) = Locate( document, path );

					if ( parent is not JsonObject owner ) continue;

					var invented = s_stormKeys[random.Next( s_stormKeys.Length )];

					owner[invented] = poisons[random.Next( poisons.Length )]?.DeepClone();
					labels.Add( $"{path}/../{invented} := <invented>" );
					continue;
				}

				if ( ReplaceAt( document, path, poisons[poison]?.DeepClone() ) )
				{
					labels.Add( $"{path} := {poisons[poison]?.ToJsonString() ?? "null"}" );
				}
			}

			if ( labels.Count == 0 ) continue;

			cases++;

			var what = string.Join( " ; ", labels );

			try
			{
				var graph = Read( document.ToJsonString( PrismJson.Options ), new DiagnosticSink() );

				if ( graph is null ) continue;

				var first = Write( graph );

				if ( !string.Equals( first, Write( graph ), StringComparison.Ordinal ) )
				{
					unstable.Add( what );
					continue;
				}

				var reread = Read( first, new DiagnosticSink() );

				if ( reread is null || !string.Equals( first, Write( reread ), StringComparison.Ordinal ) )
				{
					nonIdempotent.Add( what );
					continue;
				}

				try
				{
					GraphQueries.TryFindCycle( graph, out _ );
					GraphQueries.TopologicalOrder( graph );
					GraphQueries.Validate( graph );
					GraphQueries.ReachableFromOutputs( graph );

					_ = graph.StructureHash;
					_ = graph.ValueHash;
				}
				catch ( Exception e )
				{
					queried.Add( $"{what} -> {e.GetType().Name}: {e.Message}" );
				}
			}
			catch ( Exception e )
			{
				thrown.Add( $"{what} -> {e.GetType().Name}: {e.Message}" );
			}
		}

		check( $"{cases} documents damaged in several places at once all loaded", thrown.Count == 0,
			First( thrown ) );
		check( "every one of them wrote deterministically", unstable.Count == 0, First( unstable ) );
		check( "every one of them wrote idempotently", nonIdempotent.Count == 0, First( nonIdempotent ) );
		check( "and every one of them could still be walked", queried.Count == 0, First( queried ) );
	}

	/// <summary>
	/// <see cref="UndoStormTests"/> edits five hundred times and then unwinds. A session does not: it
	/// undoes and redoes <em>while</em> it edits, which is what pushes the stack through the states where
	/// a redo tail is discarded mid-flight. The indexes are checked after every single step, because an
	/// index that drifts is invisible until something much later cannot find a node.
	/// </summary>
	static void InterleavedUndoTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;

		var graph = new PrismGraph();
		var undo = new PrismUndoStack( graph ) { CoalesceMs = 0, Capacity = 4096 };
		var mutations = new GraphMutations( graph, undo );

		for ( int i = 0; i < 6; i++ )
		{
			mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( i * 160f, 0f ) );
		}

		mutations.AddParameter( "Rough", ShaderType.Float );
		mutations.AddKeyword( "F_A", ComboKind.Feature );
		undo.Clear();

		var start = Write( graph );
		var random = new Random( 616 );
		var outPort = PortId.Parse( "Out" );
		var inPort = PortId.Parse( "B" );
		var clipboard = (string)null;
		var failure = (string)null;
		var drift = (string)null;

		for ( int step = 0; step < 500 && failure is null; step++ )
		{
			var nodes = graph.Nodes.ToArray();

			try
			{
				switch ( random.Next( 18 ) )
				{
					case 0:
						mutations.AddNode( PrismSelfTestNode.TypeId,
							new Vector2( random.Next( -900, 900 ), random.Next( -900, 900 ) ) );
						break;

					case 1 when nodes.Length > 2:
						mutations.RemoveNode( nodes[random.Next( nodes.Length )].Id );
						break;

					case 2 when nodes.Length > 0:
						mutations.Move( nodes[random.Next( nodes.Length )].Id,
							new Vector2( random.Next( -900, 900 ), random.Next( -900, 900 ) ) );
						break;

					case 3 when nodes.Length > 1:
						mutations.Connect( new PortRef( nodes[random.Next( nodes.Length )].Id, outPort ),
							new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) );
						break;

					case 4 when graph.Edges.Count > 0:
						mutations.Disconnect( graph.Edges[random.Next( graph.Edges.Count )].Id );
						break;

					case 5 when nodes.Length > 0:
						mutations.SetProperty( nodes[random.Next( nodes.Length )].Id, "Channel", random.Next( 32 ) );
						break;

					case 6 when nodes.Length > 0:
						mutations.SetInlineValue( nodes[random.Next( nodes.Length )].Id, PortId.Parse( "C" ),
							new Color( (float)random.NextDouble(), 0.5f, 0.25f, 1f ) );
						break;

					case 7:
						mutations.AddParameter( "P" + random.Next( 5 ), ShaderType.Float3 );
						break;

					case 8 when graph.Parameters.Count > 1:
						mutations.RemoveParameter( graph.Parameters[random.Next( graph.Parameters.Count )].Id );
						break;

					case 9 when graph.Edges.Count > 0:
						mutations.InsertReroute( graph.Edges[random.Next( graph.Edges.Count )].Id,
							new Vector2( random.Next( 200 ), random.Next( 200 ) ) );
						break;

					case 10 when nodes.Length > 0:
						clipboard = mutations.Copy( nodes.Take( 3 ).Select( x => x.Id ) );
						break;

					case 11 when clipboard is not null:
						mutations.Paste( clipboard, new Vector2( random.Next( 400 ), random.Next( 400 ) ) );
						break;

					case 12 when graph.Edges.Count > 0:
						mutations.SetEdgeFill( graph.Edges[random.Next( graph.Edges.Count )].Id,
							random.Next( 2 ) == 0 ? null : (float)random.NextDouble() );
						break;

					case 13 when graph.Edges.Count > 0:
						mutations.SetEdgeVia( graph.Edges[random.Next( graph.Edges.Count )].Id,
							new[] { new Vector2( random.Next( 200 ), random.Next( 200 ) ) } );
						break;

					case 14 when graph.Parameters.Count > 0:
						mutations.RenameParameter( graph.Parameters[random.Next( graph.Parameters.Count )].Id,
							"R" + random.Next( 6 ) );
						break;

					case 15:
						mutations.AddKeyword( "F_K" + random.Next( 4 ), ComboKind.Feature );
						break;

					case 16 when nodes.Length > 1:
						mutations.Duplicate( nodes.Take( 2 ).Select( x => x.Id ), new Vector2( 40f, 40f ) );
						break;

					case 17:
						mutations.UpdateSettings( x => x.Uv2 = random.Next( 2 ) == 0 );
						break;
				}

				// The part a storm that only unwinds at the end never reaches: undoing and redoing while
				// the document is still being edited, so a new edit lands on a discarded redo tail.
				if ( random.Next( 5 ) == 0 && undo.CanUndo ) undo.Undo();
				if ( random.Next( 8 ) == 0 && undo.CanRedo ) undo.Redo();

				drift ??= IndexProblem( graph );
			}
			catch ( Exception e )
			{
				failure = $"step {step}: {e.GetType().Name}: {e.Message}";
			}
		}

		check( "500 edits interleaved with undo and redo ran without throwing", failure is null, failure );
		check( "and the node and edge indexes never drifted", drift is null, drift );

		if ( failure is not null ) return;

		var end = Write( graph );
		var guard = 0;

		while ( undo.CanUndo && guard++ < 20000 ) undo.Undo();

		check( "unwinding an interleaved history is still byte-exact", Write( graph ) == start,
			Diff( start, Write( graph ) ) );

		guard = 0;

		while ( undo.CanRedo && guard++ < 20000 ) undo.Redo();

		check( "and rewinding it is too", Write( graph ) == end, Diff( end, Write( graph ) ) );
		check( "and the stack is not left capturing", !undo.IsCapturing, null );
	}

	/// <summary>
	/// Subgraphs are the one place the document graph is not the whole graph, so they are the one place a
	/// traversal can leave the document it started in and never come back. A subgraph that instances
	/// itself has to be reported; fifty levels of nesting has to stop; and an asset that was renamed or
	/// deleted has to leave a node that says so rather than a node that is gone.
	/// </summary>
	static void SubgraphRecoveryTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( Nodes.SubgraphInstanceNode.TypeId ) is null ) return;

		try
		{
			Nodes.SubgraphLibrary.Flush();

			var self = new PrismGraph( true );

			self.AddNode( NodeRegistry.Create( Nodes.SubgraphOutputNode.TypeId ) );

			var instance = self.AddNode( NodeRegistry.Create( Nodes.SubgraphInstanceNode.TypeId ) )
				as Nodes.SubgraphInstanceNode;

			Nodes.SubgraphLibrary.Register( "prism/selftest/self.prismfn", self );

			instance.SubgraphPath = "prism/selftest/self.prismfn";

			var recursive = GraphCompiler.Compile( self, CompileMode.Final );

			check( "a subgraph that instances itself is reported, not recursed into",
				recursive is not null && recursive.Diagnostics.Any( x => x.Severity == DiagnosticSeverity.Error ),
				recursive is null ? "no result" : $"{recursive.Diagnostics.Count} diagnostics" );

			// Fifty documents, each instancing the next.
			Nodes.SubgraphLibrary.Flush();

			var chain = new List<PrismGraph>();

			for ( int i = 0; i < 50; i++ )
			{
				var document = new PrismGraph( true );

				document.AddNode( NodeRegistry.Create( Nodes.SubgraphOutputNode.TypeId ) );
				chain.Add( document );

				Nodes.SubgraphLibrary.Register( $"prism/selftest/chain{i}.prismfn", document );
			}

			for ( int i = 0; i < chain.Count - 1; i++ )
			{
				var link = chain[i].AddNode( NodeRegistry.Create( Nodes.SubgraphInstanceNode.TypeId ) )
					as Nodes.SubgraphInstanceNode;

				link.SubgraphPath = $"prism/selftest/chain{i + 1}.prismfn";
			}

			var clock = System.Diagnostics.Stopwatch.StartNew();
			var deep = GraphCompiler.Compile( chain[0], CompileMode.Final );

			check( "fifty levels of subgraph nesting terminates rather than overflowing the stack",
				deep is not null && clock.ElapsedMilliseconds < 30000, $"{clock.ElapsedMilliseconds} ms" );
			check( "and says so", deep is not null && deep.Diagnostics.Any( x => x.Severity == DiagnosticSeverity.Error ),
				null );

			// An asset that is not there, and one that came back under a new name.
			Nodes.SubgraphLibrary.Flush();

			var host = new PrismGraph();
			var missing = host.AddNode( NodeRegistry.Create( Nodes.SubgraphInstanceNode.TypeId ) )
				as Nodes.SubgraphInstanceNode;

			missing.SubgraphPath = "prism/selftest/deleted.prismfn";

			check( "an instance of a deleted subgraph reports rather than disappears", missing.IsBroken,
				missing.LoadError );

			var text = Write( host );

			check( "and round-trips", Write( Read( text, new DiagnosticSink() ) ) == text,
				Diff( text, Write( Read( text, new DiagnosticSink() ) ) ) );
			check( "and compiling around it reports rather than throws",
				GraphCompiler.Compile( host, CompileMode.Final ) is not null, null );

			var renamed = new PrismGraph( true );

			renamed.AddNode( NodeRegistry.Create( Nodes.SubgraphOutputNode.TypeId ) );
			Nodes.SubgraphLibrary.Register( "prism/selftest/renamed.prismfn", renamed );

			missing.SubgraphPath = "prism/selftest/renamed.prismfn";

			check( "and repointing it at the renamed asset clears the error", !missing.IsBroken,
				missing.LoadError );
		}
		catch ( Exception e )
		{
			check( "the subgraph recovery paths never throw", false, $"{e.GetType().Name}: {e.Message}" );
		}
		finally
		{
			PrismLog.Guard( "Clearing the self-test subgraphs", Nodes.SubgraphLibrary.Flush );
		}
	}

	/// <summary>
	/// The clipboard is the only input to the editor that comes from outside it entirely: another
	/// application, an older build, or a string a user assembled by hand. A payload damaged anywhere in
	/// its length must be refused, and refusing it must leave the document exactly as it was.
	/// </summary>
	static void ClipboardFuzzTests( Action<string, bool, string> check )
	{
		var host = Read( SelfTestDocument, new DiagnosticSink() );

		if ( host is null ) return;

		var before = Write( host );
		var payload = ClipboardCodec.Encode( host, host.Nodes.Take( 4 ).ToArray() );

		check( "a clipboard payload encodes", !string.IsNullOrEmpty( payload ), null );

		if ( string.IsNullOrEmpty( payload ) ) return;

		var random = new Random( 5150 );
		var failure = (string)null;

		for ( int iteration = 0; iteration < 1000 && failure is null; iteration++ )
		{
			var chars = payload.ToCharArray();

			for ( int edit = 0; edit < 1 + random.Next( 3 ); edit++ )
			{
				chars[random.Next( chars.Length )] = (char)( 32 + random.Next( 90 ) );
			}

			try
			{
				ClipboardCodec.Paste( host, new string( chars ), Vector2.Zero, new DiagnosticSink() );
			}
			catch ( Exception e )
			{
				failure = $"{iteration}: {e.GetType().Name}: {e.Message}";
			}
		}

		check( "a thousand damaged clipboard payloads never throw", failure is null, failure );
		check( "and the document they were pasted into is still coherent", IndexProblem( host ) is null,
			IndexProblem( host ) );
		check( "and still round-trips", Write( Read( Write( host ), new DiagnosticSink() ) ) == Write( host ),
			null );

		_ = before;
	}

	/// <summary>
	/// An undo stack is bounded, so a long session is always throwing its oldest states away. Trimming
	/// has to move the current level with them — a level that still points at where an entry used to be
	/// is a stack that restores the wrong document, and it does it silently.
	/// </summary>
	static void UndoCapacityTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;

		var graph = new PrismGraph();
		var undo = new PrismUndoStack( graph ) { CoalesceMs = 0, Capacity = 8 };
		var mutations = new GraphMutations( graph, undo );
		var states = new List<string> { Write( graph ) };

		for ( int i = 0; i < 300; i++ )
		{
			mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( i * 16f, 0f ) );
			states.Add( Write( graph ) );
		}

		check( "a bounded stack keeps exactly its capacity", undo.Count == 8, $"{undo.Count}" );
		check( "and sits at the top of what it kept", undo.Level == undo.Count, $"{undo.Level}/{undo.Count}" );

		var top = Write( graph );
		var guard = 0;

		while ( undo.CanUndo && guard++ < 100 ) undo.Undo();

		check( "undoing everything it still holds reaches the oldest state it kept",
			Write( graph ) == states[states.Count - 1 - 8], Diff( states[states.Count - 1 - 8], Write( graph ) ) );
		check( "and there is nothing further back to reach", !undo.CanUndo, null );

		guard = 0;

		while ( undo.CanRedo && guard++ < 100 ) undo.Redo();

		check( "and redoing returns to exactly where it was", Write( graph ) == top,
			Diff( top, Write( graph ) ) );

		// Trimming while the user is part-way down their history is the case that moves the level.
		undo.Undo();
		undo.Undo();
		undo.Undo();

		var midway = Write( graph );

		check( "a new edit from part-way down discards the redo branch",
			mutations.AddNode( PrismSelfTestNode.TypeId, Vector2.Zero ) is not null && !undo.CanRedo, null );

		undo.Undo();

		check( "and undoing it comes back to where the branch started", Write( graph ) == midway,
			Diff( midway, Write( graph ) ) );
	}

	/// <summary>
	/// Pasting is the one edit whose input the document did not produce: it may come from another graph,
	/// another kind of graph, an older build, or a version of the addon with node types this one has
	/// never heard of. None of those may lose anything or collide with what is already here.
	/// </summary>
	static void PasteSemanticTests( Action<string, bool, string> check )
	{
		if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;

		// A fragment whose nodes reference a blackboard parameter the target has never seen.
		var source = new PrismGraph();
		var parameter = source.AddParameter( new Parameter( "Roughness", ShaderType.Float ) { Default = 0.4f } );
		var user = source.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

		NodeProperties.Set( user, "Label", parameter.Id.Value );

		var payload = ClipboardCodec.Encode( source, source.Nodes );
		var target = new PrismGraph();
		var pasted = ClipboardCodec.Paste( target, payload, Vector2.Zero, new DiagnosticSink() );

		check( "a fragment pastes into a document that never saw it", pasted is { Ok: true }, pasted?.Error );
		check( "and brings the parameter its nodes referenced with it",
			target.Parameters.Count == 1 && target.Parameters[0].Name == "Roughness",
			string.Join( ",", target.Parameters.Select( x => x.Name ) ) );

		// The same payload pasted many times: ids are minted fresh every time or the document breaks.
		for ( int i = 0; i < 50; i++ )
		{
			ClipboardCodec.Paste( target, payload, new Vector2( i * 32f, 0f ), new DiagnosticSink() );
		}

		check( "fifty pastes of one payload never collide", IndexProblem( target ) is null, IndexProblem( target ) );
		check( "and produce fifty-one copies", target.Nodes.Count == 51, $"{target.Nodes.Count}" );
		check( "and the result round-trips",
			Write( Read( Write( target ), new DiagnosticSink() ) ) == Write( target ), null );

		// A fragment containing a node type this build does not have.
		var foreign = ClipboardCodec.EncodeJson( """
		{ "schema":1, "kind":"fragment",
		  "nodes":[ {"id":"a","type":"acme.nope.node","v":7,"pos":[0,0],
		             "props":{"Secret":[1,2,3]},"x":{"vendor":"acme"}} ] }
		""" );

		var host = new PrismGraph();
		var strange = ClipboardCodec.Paste( host, foreign, Vector2.Zero, new DiagnosticSink() );

		check( "a fragment of an unknown node type still pastes", strange is { Ok: true }, strange?.Error );
		check( "and the unknown node arrives whole",
			host.Nodes.FirstOrDefault() is UnknownNode unknown &&
			unknown.Raw?["props"]?["Secret"] is not null, null );
		check( "and survives being saved and reopened",
			Read( Write( host ), new DiagnosticSink() )?.Nodes.FirstOrDefault() is UnknownNode, null );

		// Something large enough that a per-node allocation would be noticeable.
		var big = new PrismGraph();

		for ( int i = 0; i < 500; i++ )
		{
			var node = big.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
			node.Position = new Vector2( i * 32f, 0f );
		}

		var bulk = ClipboardCodec.Encode( big, big.Nodes );
		var clock = System.Diagnostics.Stopwatch.StartNew();
		var landed = ClipboardCodec.Paste( new PrismGraph(), bulk, Vector2.Zero, new DiagnosticSink() );

		check( "a five hundred node paste lands", landed is { Ok: true } && landed.Nodes.Count == 500,
			$"{landed?.Nodes.Count}" );
		check( "and lands promptly", clock.ElapsedMilliseconds < 5000, $"{clock.ElapsedMilliseconds} ms" );

		// Pasting a shader fragment into a subgraph, and the other way round.
		var subgraph = new PrismGraph( true );

		check( "a shader fragment pastes into a subgraph",
			ClipboardCodec.Paste( subgraph, payload, Vector2.Zero, new DiagnosticSink() ) is { Ok: true }, null );
		check( "and the subgraph is still a subgraph", subgraph.IsSubgraph, null );
	}

	/// <summary>
	/// Importing somebody else's <c>.shdrgrph</c> is the one path where the input was produced by another
	/// program entirely, so nothing about its shape can be assumed.
	/// </summary>
	static void LegacyCorruptionTests( Action<string, bool, string> check )
	{
		var paths = new List<string>();

		CollectPaths( JsonNode.Parse( LegacyDocument ), string.Empty, paths, 0 );

		var poisons = Poisons();
		var thrown = new List<string>();
		var lost = new List<string>();
		var cases = 0;

		foreach ( var path in paths )
		{
			for ( int p = -1; p < poisons.Length; p++ )
			{
				var document = JsonNode.Parse( LegacyDocument ) as JsonObject;
				var label = $"{path} := {( p < 0 ? "<removed>" : poisons[p]?.ToJsonString() ?? "null" )}";

				if ( p < 0 )
				{
					if ( !RemoveAt( document, path ) ) continue;
				}
				else if ( !ReplaceAt( document, path, poisons[p]?.DeepClone() ) )
				{
					continue;
				}

				cases++;

				try
				{
					var imported = LegacyShaderGraphImporter.Import( document.ToJsonString( PrismJson.Options ),
						false, new DiagnosticSink() );

					if ( imported is null ) continue;

					var text = Write( imported );

					if ( !string.Equals( text, Write( Read( text, new DiagnosticSink() ) ), StringComparison.Ordinal ) )
					{
						lost.Add( label );
					}
				}
				catch ( Exception e )
				{
					thrown.Add( $"{label} -> {e.GetType().Name}: {e.Message}" );
				}
			}
		}

		check( $"{cases} corruptions of a legacy document were all importable", thrown.Count == 0,
			First( thrown ) );
		check( "and everything they produced round-tripped", lost.Count == 0, First( lost ) );

		foreach ( var junk in new[] { null, "", "   ", "not json", "[]", "42", "{}", "{\"nodes\":5}" } )
		{
			try
			{
				LegacyShaderGraphImporter.Import( junk, false, new DiagnosticSink() );
			}
			catch ( Exception e )
			{
				check( "importing junk never throws", false, $"{e.GetType().Name}: {e.Message}" );
				return;
			}
		}

		check( "importing junk never throws", true, null );
	}

	/// <summary>
	/// A <c>.prism</c> is a <c>GameResource</c>, and <c>GameResource.Serialize()</c> is what
	/// <c>Asset.SaveToDisk</c> writes over the source file.
	/// <para>
	/// That path is reachable from the asset inspector's Save button, its <c>CTRL+S</c> shortcut, its
	/// discard prompt and the editor's own quit dialog — none of which Prism controls. A resource that
	/// serializes to less than it loaded therefore replaces the user's graph in place, with no undo. It
	/// used to: <c>OnJsonSerialize</c> unconditionally wrote a one-node skeleton with a fresh random id.
	/// </para>
	/// <para>
	/// This drives the real engine entry points — <c>LoadFromJson</c> and <c>Serialize</c> — rather than
	/// Prism's own reader, because the thing being checked is precisely the boundary between the two.
	/// Note that <c>[JsonExtensionData]</c> would not have worked here: s&amp;box's <c>Json</c> helpers
	/// are hand-written property walkers and implement no such thing.
	/// </para>
	/// </summary>
	static void GameResourceTests( Action<string, bool, string> check )
	{
		const string document = """
			{
			  "schema": 1,
			  "id": "a7f3k2b9",
			  "kind": "shader",
			  "meta": { "title": "Tinted" },
			  "settings": { "domain": "Surface" },
			  "parameters": [ { "id": "zwe5qxbe", "name": "Tint", "type": "float4" } ],
			  "nodes": [
			    { "id": "i5epv2so", "type": "prism.output.surface", "v": 1, "pos": [ 320, 0 ] },
			    { "id": "0k03jodr", "type": "prism.math.multiply", "v": 1, "pos": [ 0, 0 ] }
			  ],
			  "edges": [
			    { "id": "oumdfn0v", "from": { "node": "0k03jodr", "port": "Out" },
			      "to": { "node": "i5epv2so", "port": "Albedo" } }
			  ]
			}
			""";

		JsonObject first = null;
		string text = null;

		var loaded = PrismLog.Guard( "Round-tripping a Prism GameResource", () =>
		{
			var resource = new Sandbox.Prism.PrismGraphFile();

			resource.LoadFromJson( document );

			first = resource.Serialize();
			first.Remove( "__version" );
			text = first.ToJsonString();

			var again = resource.Serialize();
			again.Remove( "__version" );

			return text == again.ToJsonString();
		} );

		check( "a .prism GameResource keeps its nodes through a serialize",
			first?["nodes"] is JsonArray nodes && nodes.Count == 2,
			first is null ? "the round trip threw" : $"got {( first["nodes"] as JsonArray )?.Count ?? 0} nodes" );

		check( "a .prism GameResource keeps its edges through a serialize",
			first?["edges"] is JsonArray edges && edges.Count == 1, null );

		check( "a .prism GameResource keeps its parameters through a serialize",
			first?["parameters"] is JsonArray parameters && parameters.Count == 1, null );

		check( "a .prism GameResource keeps its document id through a serialize",
			ValueCodec.StringOf( first?["id"] ) == "a7f3k2b9",
			$"got '{ValueCodec.StringOf( first?["id"] )}'" );

		// Non-deterministic serialization makes HasUnsavedChanges meaningless — the asset inspector
		// hashes Serialize() to decide whether the resource is dirty, and a fresh random id every time
		// would arm the Save button that overwrites the file.
		check( "a .prism GameResource serializes deterministically", loaded, null );

		// Everything that came back must still read as the document it started as.
		var reread = PrismLog.Guard<PrismGraph>( "Reading back a serialized GameResource",
			() => text is null ? null : Read( text ) );

		check( "what the GameResource gave back still reads as the same graph",
			reread is not null && reread.Nodes.Count() == 2 && reread.DocumentId == "a7f3k2b9", null );

		// The New ▸ Shader path serializes a brand-new instance, and that IS the case a skeleton is for.
		var skeleton = PrismLog.Guard( "Serializing a fresh Prism GameResource",
			() => new Sandbox.Prism.PrismGraphFile().Serialize() );

		check( "a brand-new .prism resource still serializes as a usable skeleton",
			skeleton?["nodes"] is JsonArray seeded && seeded.Count == 1, null );

		var subgraph = PrismLog.Guard( "Serializing a fresh Prism subgraph resource",
			() => new Sandbox.Prism.PrismSubgraphFile().Serialize() );

		check( "a brand-new .prismfn resource seeds an input and an output",
			subgraph?["nodes"] is JsonArray pair && pair.Count == 2, null );
	}

	/// <summary>
	/// Writing a document is the one operation that can destroy work that already exists, so it re-reads
	/// what it produced before it touches the file on disk, and refuses rather than replacing an original
	/// with something that will not open.
	/// </summary>
	static void SaveIntegrityTests( Action<string, bool, string> check )
	{
		var folder = Path.Combine( Path.GetTempPath(), "prism-selftest-" + Ids.NewShortId() );
		var path = Path.Combine( folder, "document." + PrismConstants.GraphExtension );

		try
		{
			var graph = Read( SelfTestDocument, new DiagnosticSink() );

			if ( graph is null ) return;

			var sink = new DiagnosticSink();

			check( "a document saves to a path that did not exist yet", Save( graph, path, sink ), null );
			check( "and says nothing while doing it", !sink.HasErrors,
				string.Join( " | ", sink.All.Select( x => x.ToString() ) ) );
			check( "and nothing drifted on the way through the integrity check",
				!sink.All.Any( x => x.Code == DiagnosticCode.RoundTripFailed ),
				string.Join( " | ", sink.All.Select( x => x.Detail ) ) );
			check( "and the file is there", File.Exists( path ), path );
			check( "and no temp file was left beside it", !File.Exists( path + ".tmp" ), null );

			var reloaded = ReadFile( path, new DiagnosticSink() );

			check( "and it reads back as the same document",
				reloaded is not null && Write( reloaded ) == Write( graph ),
				Diff( Write( graph ), reloaded is null ? "<null>" : Write( reloaded ) ) );

			// Saving over an existing file goes through File.Replace rather than Move, which is the branch
			// that only runs from the second save onwards.
			graph.Meta.Title = "Saved Twice";

			check( "and saving over it again works", Save( graph, path, new DiagnosticSink() ), null );
			check( "and the second save is the one that is there",
				ReadFile( path, new DiagnosticSink() )?.Meta.Title == "Saved Twice", null );
			check( "and still left no temp file", !File.Exists( path + ".tmp" ), null );

			// A destination that cannot be written has to fail without touching what is already there.
			var before = File.ReadAllText( path );

			check( "saving into a file as though it were a folder fails",
				!Save( graph, Path.Combine( path, "nested.prism" ), new DiagnosticSink() ), null );
			check( "and leaves the real document untouched", File.ReadAllText( path ) == before, null );
		}
		catch ( Exception e )
		{
			check( "saving a document never throws", false, $"{e.GetType().Name}: {e.Message}" );
		}
		finally
		{
			PrismLog.Guard( "Removing the self-test save folder", () =>
			{
				if ( Directory.Exists( folder ) ) Directory.Delete( folder, true );
			} );
		}
	}

	/// <summary>
	/// Killing a compile is not an edge case: it is what every keystroke does. What must never survive
	/// one is a latched flag, a running counter or a file nobody owns.
	/// </summary>
	static void CompileServiceTests( Action<string, bool, string> check )
	{
		Toolchain.ShaderCompileService service = null;

		try
		{
			service = new Toolchain.ShaderCompileService( "prismselftest2" );
			service.ReloadShaders = false;

			check( "a force-regenerate request latches until something consumes it",
				!service.ForceRegenerate, null );

			service.ForceRegenerate = true;
			service.Cancel();

			check( "and cancelling a compile does not consume it", service.ForceRegenerate, null );

			var graph = new PrismGraph();

			graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );

			// Twenty requests fired at once: half killed before they are issued, half killed after they
			// are already inside the compile. The second half is the one that matters — a compile killed
			// mid-flight is waiting on a main-thread hop, and until that wait became cancellable there
			// was no way for it to ever come back and clear the running counter.
			var tasks = new List<Task>();
			var sources = new List<CancellationTokenSource>();

			for ( int i = 0; i < 20; i++ )
			{
				var source = new CancellationTokenSource();

				sources.Add( source );

				if ( i % 2 == 0 ) source.Cancel();

				tasks.Add( service.RequestNow( graph, CompileMode.Preview, source.Token ) );
			}

			foreach ( var source in sources ) source.Cancel();

			PrismLog.Guard( "Awaiting twenty concurrent compiles",
				() => Task.WaitAll( tasks.ToArray(), 20000 ) );

			check( "twenty concurrent compiles, every one of them killed, all settle",
				tasks.All( x => x.IsCompleted ), $"{tasks.Count( x => !x.IsCompleted )} still running" );
			check( "and nothing is left compiling", !service.IsCompiling, null );
			check( "and leave no file nobody owns in the scratch folder",
				service.Workspace.Files.Count == 0, string.Join( ",", service.Workspace.Files ) );

			foreach ( var source in sources ) PrismLog.Guard( "Disposing a self-test token", source.Dispose );

			service.Dispose();
			service.Dispose();

			check( "a disposed compile service is inert", !service.IsCompiling, null );
		}
		catch ( Exception e )
		{
			check( "the compile service never throws", false, $"{e.GetType().Name}: {e.Message}" );
		}
		finally
		{
			PrismLog.Guard( "Disposing the self-test compile service", () => service?.Dispose() );
		}
	}

	// ---------------------------------------------------------------- fault-injection helpers ----

	static bool IsFinite( Vector2 value ) => float.IsFinite( value.x ) && float.IsFinite( value.y );

	static string First( IReadOnlyList<string> failures ) =>
		failures.Count == 0 ? null : $"{failures.Count} of them, e.g. {failures[0]}";

	/// <summary>Every addressable location in a document, as <c>a.b[0].c</c> paths.</summary>
	static void CollectPaths( JsonNode node, string path, List<string> into, int depth )
	{
		if ( node is null || depth > 6 ) return;

		switch ( node )
		{
			case JsonObject obj:
				foreach ( var pair in obj )
				{
					var child = path.Length == 0 ? pair.Key : $"{path}.{pair.Key}";
					into.Add( child );
					CollectPaths( pair.Value, child, into, depth + 1 );
				}

				break;

			case JsonArray array:
				for ( int i = 0; i < array.Count; i++ )
				{
					var child = $"{path}[{i}]";
					into.Add( child );
					CollectPaths( array[i], child, into, depth + 1 );
				}

				break;
		}
	}

	/// <summary>Split <c>a.b[0].c</c> into its member and index steps.</summary>
	static List<string> PathSteps( string path )
	{
		var steps = new List<string>();
		var current = new StringBuilder();

		for ( int i = 0; i < path.Length; i++ )
		{
			var c = path[i];

			if ( c == '.' )
			{
				if ( current.Length > 0 ) { steps.Add( current.ToString() ); current.Clear(); }
				continue;
			}

			if ( c == '[' )
			{
				if ( current.Length > 0 ) { steps.Add( current.ToString() ); current.Clear(); }

				var close = path.IndexOf( ']', i );

				if ( close < 0 ) break;

				steps.Add( "#" + path[( i + 1 )..close] );
				i = close;
				continue;
			}

			current.Append( c );
		}

		if ( current.Length > 0 ) steps.Add( current.ToString() );

		return steps;
	}

	static (JsonNode Parent, string Key, int Index) Locate( JsonObject root, string path )
	{
		JsonNode current = root;
		var steps = PathSteps( path );

		for ( int i = 0; i < steps.Count; i++ )
		{
			var step = steps[i];
			var isIndex = step.StartsWith( "#", StringComparison.Ordinal );
			var index = isIndex ? int.Parse( step[1..], CultureInfo.InvariantCulture ) : -1;

			if ( i == steps.Count - 1 ) return isIndex ? (current, null, index) : (current, step, -1);

			current = isIndex ? ( current as JsonArray )?[index] : ( current as JsonObject )?[step];

			if ( current is null ) return (null, null, -1);
		}

		return (null, null, -1);
	}

	static bool RemoveAt( JsonObject root, string path )
	{
		var (parent, key, index) = Locate( root, path );

		if ( parent is JsonObject obj && key is not null ) return obj.Remove( key );

		if ( parent is JsonArray array && index >= 0 && index < array.Count )
		{
			array.RemoveAt( index );
			return true;
		}

		return false;
	}

	static bool ReplaceAt( JsonObject root, string path, JsonNode value )
	{
		var (parent, key, index) = Locate( root, path );

		if ( parent is JsonObject obj && key is not null )
		{
			obj[key] = value;
			return true;
		}

		if ( parent is JsonArray array && index >= 0 && index < array.Count )
		{
			array[index] = value;
			return true;
		}

		return false;
	}

	/// <summary>A small but representative built-in shader graph, in the legacy format.</summary>
	const string LegacyDocument = """
{
  "Model": null,
  "Description": "legacy fixture",
  "BlendMode": "Masked",
  "nodes": [
    {
      "_class": "Result",
      "DefaultOpacity": 1, "DefaultRoughness": 1, "DefaultMetalness": 0, "DefaultAmbientOcclusion": 1,
      "Identifier": "0",
      "Position": "-1212,-828",
      "Albedo": { "Identifier": "1", "Output": "Result" },
      "Roughness": { "Identifier": "2", "Output": "Result" }
    },
    {
      "_class": "Multiply",
      "DefaultA": 0, "DefaultB": 1,
      "Identifier": "1", "Position": "-2688,684",
      "A": { "Identifier": "3", "Output": "Result" }
    },
    {
      "_class": "Float",
      "Min": 0, "Max": 1, "Value": 0.25,
      "Name": "RimFudge", "IsAttribute": false, "UI": {},
      "Identifier": "2", "Position": "-2460,-1572"
    },
    {
      "_class": "TotallyMadeUpNode",
      "SecretSetting": 42,
      "Identifier": "3", "Position": "-3000,-100"
    },
    {
      "_class": "Float4",
      "Value": "1,0.5,0.25,1", "Name": "", "IsAttribute": false, "UI": { "Type": "Color" },
      "Identifier": "5", "Position": "-3600,-600"
    },
    {
      "_class": "Subtract",
      "DefaultA": 0, "DefaultB": 1,
      "Identifier": "6", "Position": "-3900,-600",
      "A": { "Identifier": "5", "Output": "R" }
    },
    {
      "_class": "TextureSampler",
      "Image": "textures/fixture/rust_albedo.png",
      "Sampler": { "Filter": "Aniso", "AddressU": "Clamp", "AddressV": "Mirror_Once" },
      "UI": {
        "Name": "RustAlbedo", "IsAttribute": false, "SrgbRead": false,
        "PrimaryGroup": { "Name": "Surface", "Priority": 0 }
      },
      "Identifier": "4", "Position": "-3300,-300"
    }
  ]
}
""";

	/// <summary>The worked example from section 3.3 of the architecture brief, verbatim.</summary>
	public const string SelfTestDocument = """
{
  "schema": 1,
  "id": "g4k2x9pq",
  "kind": "shader",
  "meta": {
    "title": "Wet Stone",
    "description": "Layered stone with puddle wetness driven by a mask.",
    "category": "Materials",
    "icon": "water_drop",
    "created": "2026-08-10T14:02:11Z",
    "modified": "2026-08-10T16:44:02Z",
    "editorVersion": "0.1.0"
  },
  "settings": {
    "domain": "Surface",
    "shadingModel": "Lit",
    "blendMode": "Opaque",
    "cullMode": "Back",
    "modes": [ "Forward", "Depth", "ToolsShadingComplexity" ],
    "targets": [ "sbox-hlsl", "slang" ],
    "hlslDialect": "SboxSlang",
    "uv2": true,
    "renderBackfaces": false
  },
  "preview": {
    "mesh": "Sphere",
    "model": null,
    "envmap": "textures/cubemaps/default2.vtex",
    "showGround": false,
    "showSkybox": true,
    "tint": "1,1,1,1",
    "background": "0.05,0.06,0.07,1",
    "camera": { "yaw": 135, "pitch": 30, "distance": 150 }
  },
  "parameters": [
    {
      "id": "p1",
      "name": "Base Color",
      "type": "Texture2D",
      "default": { "path": "materials/dev/white_color.tga", "colorSpace": "Srgb", "processor": "None" },
      "ui": { "group": "Textures", "order": 10 }
    },
    {
      "id": "p2",
      "name": "Roughness",
      "type": "float",
      "default": 0.62,
      "ui": { "control": "Slider", "min": 0, "max": 1, "step": 0.01, "group": "Surface", "order": 20 }
    },
    {
      "id": "p3",
      "name": "Wetness",
      "type": "float",
      "default": 0,
      "attribute": "PrismWetness",
      "ui": { "control": "Slider", "min": 0, "max": 1, "group": "Surface", "order": 30 }
    }
  ],
  "keywords": [
    { "id": "k1", "name": "F_PUDDLES", "kind": "Feature", "values": [ "Off", "On" ], "default": 0, "group": "Wetness" }
  ],
  "nodes": [
    { "id": "n1", "type": "prism.output.surface", "v": 1, "pos": [ 768, 0 ],
      "props": {}, "inline": { "Metalness": 0.0, "AmbientOcclusion": 1.0 } },

    { "id": "n2", "type": "prism.input.texcoord", "v": 1, "pos": [ -512, -32 ],
      "props": { "Channel": 0 } },

    { "id": "n3", "type": "prism.uv.tileAndOffset", "v": 1, "pos": [ -288, -32 ],
      "props": {}, "inline": { "Tile": [ 4, 4 ], "Offset": [ 0, 0 ] } },

    { "id": "n4", "type": "prism.texture.sample2d", "v": 2, "pos": [ -32, -48 ],
      "props": {
        "Parameter": "p1",
        "Sampler": { "filter": "Aniso", "addressU": "Wrap", "addressV": "Wrap" },
        "MipMode": "Auto"
      },
      "flags": { "preview": true } },

    { "id": "n5", "type": "prism.parameter.ref", "v": 1, "pos": [ -32, 176 ],
      "props": { "Parameter": "p2" } },

    { "id": "n6", "type": "prism.parameter.ref", "v": 1, "pos": [ -32, 248 ],
      "props": { "Parameter": "p3" } },

    { "id": "n7", "type": "prism.math.lerp", "v": 1, "pos": [ 288, 192 ],
      "props": {}, "inline": { "B": 0.05 } },

    { "id": "n8", "type": "prism.util.reroute", "v": 1, "pos": [ 560, 200 ], "props": {} },

    { "id": "n9", "type": "acme.fancy.customNoise", "v": 3, "pos": [ -288, 384 ],
      "props": { "Octaves": 5, "Lacunarity": 2.03, "SecretSauce": [ 1, 2, 3 ] },
      "x": { "vendor": "acme", "license": "MIT" } }
  ],
  "edges": [
    { "id": "e1", "from": { "node": "n2", "port": "UV"  }, "to": { "node": "n3", "port": "UV" } },
    { "id": "e2", "from": { "node": "n3", "port": "Out" }, "to": { "node": "n4", "port": "UV" } },
    { "id": "e3", "from": { "node": "n4", "port": "RGB" }, "to": { "node": "n1", "port": "Albedo" } },
    { "id": "e4", "from": { "node": "n5", "port": "Out" }, "to": { "node": "n7", "port": "A" } },
    { "id": "e5", "from": { "node": "n6", "port": "Out" }, "to": { "node": "n7", "port": "T" } },
    { "id": "e6", "from": { "node": "n7", "port": "Out" }, "to": { "node": "n8", "port": "In" } },
    { "id": "e7", "from": { "node": "n8", "port": "Out" }, "to": { "node": "n1", "port": "Roughness" },
      "via": [ [ 672, 200 ] ] }
  ],
  "groups": [
    { "id": "gr1", "title": "Base Colour", "description": "UV -> tile -> sample",
      "rect": [ -576, -128, 704, 224 ], "color": "Blue", "layer": 0 }
  ],
  "notes": [
    { "id": "nt1", "pos": [ -576, 448 ], "size": [ 260, 96 ], "color": "Yellow",
      "text": "acme.fancy.customNoise is a third-party node. It round-trips even if the plugin is missing." }
  ],
  "view": { "center": [ 96, 48 ], "zoom": 0.9 }
}
""";
}