V2/Application/PersistentSceneIdentityIndex.cs

Immutable index of persistent scene identities. Builds a list of SceneIdentityResolution from SceneIdentityCandidate inputs, excludes entries with missing or duplicate stable paths or IDs, and provides lookup by stable path or SceneEntityId.

Reflection
#nullable enable

using System;
using System.Collections.Generic;
using Hexagon.V2.Domain;

namespace Hexagon.V2.Application;

/// <summary>
/// Immutable O(N) scene identity index. Missing IDs, duplicate IDs, and
/// duplicate stable paths are excluded from lookup and remain fail-closed.
/// </summary>
public sealed class PersistentSceneIdentityIndex
{
	private readonly IReadOnlyList<SceneIdentityResolution> _resolutions;
	private readonly Dictionary<string, SceneIdentityResolution> _byPath;
	private readonly Dictionary<SceneEntityId, SceneIdentityResolution> _byId;

	private PersistentSceneIdentityIndex(
		IReadOnlyList<SceneIdentityResolution> resolutions,
		Dictionary<string, SceneIdentityResolution> byPath,
		Dictionary<SceneEntityId, SceneIdentityResolution> byId )
	{
		_resolutions = resolutions;
		_byPath = byPath;
		_byId = byId;
	}

	public IReadOnlyList<SceneIdentityResolution> Resolutions => _resolutions;
	public int Count => _resolutions.Count;

	public bool TryResolvePath( string stablePath, out SceneIdentityResolution resolution ) =>
		_byPath.TryGetValue( stablePath, out resolution! );

	public bool TryResolveId( SceneEntityId id, out SceneIdentityResolution resolution ) =>
		_byId.TryGetValue( id, out resolution! );

	public static PersistentSceneIdentityIndex Build( IEnumerable<SceneIdentityCandidate> candidates )
	{
		ArgumentNullException.ThrowIfNull( candidates );
		var materialized = new List<SceneIdentityCandidate>();
		var idCounts = new Dictionary<SceneEntityId, int>();
		var pathCounts = new Dictionary<string, int>( StringComparer.Ordinal );
		foreach ( var candidate in candidates )
		{
			if ( candidate is null ) throw new ArgumentException( "Scene identity candidates cannot contain null.", nameof(candidates) );
			if ( string.IsNullOrWhiteSpace( candidate.StablePath ) )
				throw new ArgumentException( "Scene identity stable paths cannot be blank.", nameof(candidates) );
			materialized.Add( candidate );
			if ( candidate.Provenance != SceneIdentityProvenance.EditorAuthored ) continue;
			pathCounts[candidate.StablePath] = pathCounts.GetValueOrDefault( candidate.StablePath ) + 1;
			if ( candidate.Id is not null ) idCounts[candidate.Id.Value] = idCounts.GetValueOrDefault( candidate.Id.Value ) + 1;
		}

		var resolutions = new SceneIdentityResolution[materialized.Count];
		var byPath = new Dictionary<string, SceneIdentityResolution>( StringComparer.Ordinal );
		var byId = new Dictionary<SceneEntityId, SceneIdentityResolution>();
		for ( var index = 0; index < materialized.Count; index++ )
		{
			var candidate = materialized[index];
			string? diagnostic = null;
			if ( candidate.Provenance != SceneIdentityProvenance.EditorAuthored )
				diagnostic = $"Persistent scene entity '{candidate.StablePath}' originated from a runtime/network root; entity is disabled.";
			else if ( pathCounts[candidate.StablePath] > 1 )
				diagnostic = $"Persistent scene path '{candidate.StablePath}' is duplicated; entity is disabled.";
			else if ( candidate.Id is null )
				diagnostic = $"Persistent scene entity '{candidate.StablePath}' has no editor-authored ID.";
			else if ( idCounts[candidate.Id.Value] > 1 )
				diagnostic = $"Persistent scene entity ID '{candidate.Id}' is duplicated; entity is disabled.";

			var resolution = new SceneIdentityResolution(
				candidate.StablePath,
				candidate.Id,
				candidate.Provenance == SceneIdentityProvenance.EditorAuthored
					? candidate.Id
					: null,
				candidate.Provenance,
				diagnostic is null,
				false,
				diagnostic );
			resolutions[index] = resolution;
			if ( diagnostic is not null ) continue;
			byPath.Add( candidate.StablePath, resolution );
			byId.Add( candidate.Id!.Value, resolution );
		}

		return new PersistentSceneIdentityIndex( resolutions, byPath, byId );
	}
}