Editor/SyncToolFlowCanonicalizer.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;

/// <summary>
/// Canonical normalizer for endpoint and workflow step routing.
///
/// Mirrors <c>tools/sbox/flow-routing.js</c> (<c>normalizeStepRoutes</c>,
/// <c>normalizeRoutes</c>, <c>normalizeRouteOutcome</c>, <c>legacyOnFailToRoute</c>)
/// from the website so the Sync Tool's diff classifier and diff view see the
/// same canonical shape regardless of authoring shape.
///
/// Without this, a local file using canonical <c>routes.true</c> /
/// <c>routes.false</c> compares as fully different from a remote definition
/// that still carries legacy <c>onFail</c>, even when the two are
/// behaviorally identical. Keeping the normalizer in sync with the JS
/// implementation is important — drift would silently re-introduce false
/// diffs in the editor.
/// </summary>
public static class SyncToolFlowCanonicalizer
{
	private static readonly HashSet<string> RouteActions = new( StringComparer.Ordinal )
	{
		"continue",
		"return",
		"reject",
		"goto",
		"run",
	};

	/// <summary>
	/// Normalize the steps array of an endpoint or workflow. Returns null when
	/// the input is not an array so callers can leave the original value in place.
	/// </summary>
	public static List<object> NormalizeSteps( object steps )
	{
		var list = ToList( steps );
		if ( list == null ) return null;

		var result = new List<object>( list.Count );
		foreach ( var step in list )
			result.Add( NormalizeStepRoutes( step ) );
		return result;
	}

	/// <summary>
	/// Normalize one step. If the step has any routing fields (<c>routes</c>,
	/// <c>onTrue</c>, <c>onFalse</c>, <c>onFail</c>) or is a condition step,
	/// rewrite into canonical <c>routes.{true,false}</c> form and drop the
	/// legacy fields. Nested step lists are normalized recursively.
	/// </summary>
	public static object NormalizeStepRoutes( object step )
	{
		var dict = ToDictionary( step );
		if ( dict == null ) return step;

		if ( dict.TryGetValue( "steps", out var nested ) )
		{
			var nestedList = NormalizeSteps( nested );
			if ( nestedList != null ) dict["steps"] = nestedList;
		}

		var typeStr = dict.TryGetValue( "type", out var typeValue ) ? typeValue as string : null;
		var hasCondition = string.Equals( typeStr, "condition", StringComparison.Ordinal );
		var hasRouteFields =
			dict.ContainsKey( "routes" )
			|| dict.ContainsKey( "onTrue" )
			|| dict.ContainsKey( "onFalse" )
			|| dict.ContainsKey( "onFail" );

		if ( hasCondition || hasRouteFields )
		{
			dict["routes"] = NormalizeRoutes( dict );
			dict.Remove( "onTrue" );
			dict.Remove( "onFalse" );
			dict.Remove( "onFail" );
		}

		return dict;
	}

	private static Dictionary<string, object> NormalizeRoutes( IDictionary<string, object> step )
	{
		var existing = step.TryGetValue( "routes", out var routes )
			? ToDictionary( routes ) ?? new Dictionary<string, object>()
			: new Dictionary<string, object>();

		var trueSource = FirstNonNull(
			step.TryGetValue( "onTrue", out var onTrue ) ? onTrue : null,
			existing.TryGetValue( "true", out var existingTrue ) ? existingTrue : null,
			existing.TryGetValue( "pass", out var existingPass ) ? existingPass : null,
			DefaultRoute( "continue" )
		);

		var legacyFalse = LegacyOnFailToRoute(
			step.TryGetValue( "onFail", out var onFail ) ? onFail : null );

		var falseSource = FirstNonNull(
			step.TryGetValue( "onFalse", out var onFalse ) ? onFalse : null,
			existing.TryGetValue( "false", out var existingFalse ) ? existingFalse : null,
			existing.TryGetValue( "fail", out var existingFail ) ? existingFail : null,
			legacyFalse
		);

		return new Dictionary<string, object>
		{
			["true"] = NormalizeRouteOutcome( trueSource, "continue" ),
			["false"] = NormalizeRouteOutcome( falseSource, "reject" ),
		};
	}

	private static object NormalizeRouteOutcome( object route, string defaultAction )
	{
		// undefined / null / true → defaultAction (matches JS `route ?? true`).
		if ( route is null ) return DefaultRoute( defaultAction );
		if ( route is bool boolean ) return DefaultRoute( boolean ? defaultAction : "reject" );

		if ( route is string text )
		{
			if ( RouteActions.Contains( text ) ) return DefaultRoute( text );
			if ( text == "skip" )
				return new Dictionary<string, object> { ["action"] = "skip", ["count"] = 1L };
			return new Dictionary<string, object> { ["action"] = "goto", ["step"] = text };
		}

		var dict = ToDictionary( route );
		if ( dict == null ) return DefaultRoute( defaultAction );

		var rawAction = (dict.TryGetValue( "action", out var actionValue ) ? actionValue as string : null)
			?? (dict.TryGetValue( "type", out var typeValue ) ? typeValue as string : null)
			?? defaultAction;

		// Preserve all extra fields so behavior-relevant data (status, error,
		// message, retry, etc.) survives canonicalization.
		var copy = new Dictionary<string, object>( dict );

		if ( rawAction == "step" )
		{
			copy["action"] = "goto";
			copy["step"] = FirstNonNull(
				dict.TryGetValue( "step", out var s ) ? s : null,
				dict.TryGetValue( "target", out var t ) ? t : null,
				dict.TryGetValue( "goto", out var g ) ? g : null
			);
			return copy;
		}

		if ( rawAction == "workflow" || rawAction == "flow" || rawAction == "endpoint" )
		{
			copy["action"] = "run";
			copy["flow"] = FirstNonNull(
				dict.TryGetValue( "flow", out var f ) ? f : null,
				dict.TryGetValue( "workflow", out var w ) ? w : null,
				dict.TryGetValue( "endpoint", out var e ) ? e : null,
				dict.TryGetValue( "target", out var tg ) ? tg : null
			);
			return copy;
		}

		if ( rawAction == "skip" )
		{
			copy["action"] = "skip";
			return copy;
		}

		copy["action"] = RouteActions.Contains( rawAction ) ? rawAction : defaultAction;
		return copy;
	}

	private static object LegacyOnFailToRoute( object onFail )
	{
		if ( onFail is string str )
		{
			if ( str == "skip" )
				return new Dictionary<string, object> { ["action"] = "skip", ["count"] = 1L };
			// Bare strings other than "skip" aren't a documented legacy form.
			// Fall through to the default reject so we don't fabricate routing.
			return DefaultRoute( "reject" );
		}

		var dict = ToDictionary( onFail );
		if ( dict == null ) return DefaultRoute( "reject" );

		var skip = FirstNonNull(
			dict.TryGetValue( "skip", out var s ) ? s : null,
			dict.TryGetValue( "skipSteps", out var ss ) ? ss : null,
			dict.TryGetValue( "steps", out var st ) ? st : null
		);
		var actionIsSkip = dict.TryGetValue( "action", out var actionValue )
			&& string.Equals( actionValue as string, "skip", StringComparison.Ordinal );

		if ( actionIsSkip || skip != null )
		{
			if ( skip is string skipStr && skipStr != "next" )
				return new Dictionary<string, object>
				{
					["action"] = "goto",
					["step"] = skipStr,
					["legacySkip"] = true,
				};

			var skipResult = new Dictionary<string, object> { ["action"] = "skip" };
			if ( skip != null ) skipResult["skip"] = skip;
			skipResult["count"] = skip switch
			{
				long l => l,
				int i => (long)i,
				double d => (long)d,
				_ => 1L,
			};
			skipResult["legacySkip"] = true;
			return skipResult;
		}

		var copy = new Dictionary<string, object>( dict );
		var rejectFalse = copy.TryGetValue( "reject", out var rejectValue )
			&& rejectValue is bool rb && !rb;
		copy["action"] = rejectFalse ? "continue" : "reject";
		return copy;
	}

	private static Dictionary<string, object> DefaultRoute( string action ) =>
		new() { ["action"] = action };

	private static Dictionary<string, object> ToDictionary( object value )
	{
		if ( value is Dictionary<string, object> dict )
			return new Dictionary<string, object>( dict );

		if ( value is IDictionary<string, object> idict )
			return new Dictionary<string, object>( idict );

		if ( value is JsonElement el && el.ValueKind == JsonValueKind.Object )
		{
			var result = new Dictionary<string, object>();
			foreach ( var prop in el.EnumerateObject() )
				result[prop.Name] = JsonElementToObject( prop.Value );
			return result;
		}

		return null;
	}

	private static List<object> ToList( object value )
	{
		if ( value is List<object> list ) return list;

		if ( value is JsonElement el && el.ValueKind == JsonValueKind.Array )
		{
			var result = new List<object>();
			foreach ( var item in el.EnumerateArray() )
				result.Add( JsonElementToObject( item ) );
			return result;
		}

		return null;
	}

	private static object JsonElementToObject( JsonElement el )
	{
		switch ( el.ValueKind )
		{
			case JsonValueKind.Object:
				var dict = new Dictionary<string, object>();
				foreach ( var prop in el.EnumerateObject() )
					dict[prop.Name] = JsonElementToObject( prop.Value );
				return dict;
			case JsonValueKind.Array:
				var list = new List<object>();
				foreach ( var item in el.EnumerateArray() )
					list.Add( JsonElementToObject( item ) );
				return list;
			case JsonValueKind.String:
				return el.GetString();
			case JsonValueKind.Number:
				return el.TryGetInt64( out var l ) ? (object)l : el.GetDouble();
			case JsonValueKind.True:
				return true;
			case JsonValueKind.False:
				return false;
			default:
				return null;
		}
	}

	private static object FirstNonNull( params object[] candidates )
	{
		foreach ( var candidate in candidates )
		{
			if ( candidate != null ) return candidate;
		}
		return null;
	}
}