Editor/TestWindow.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;

/// <summary>
/// Editor window for testing Network Storage endpoints via dry-run.
/// Proper dropdown for endpoint selection, smart input generation from game values.
/// </summary>
[Dock( "Editor", "Network Storage Endpoint Tests", "bug_report" )]
public class TestWindow : DockWindow
{
	private bool _busy;
	private string _status = "";
	private float _scrollY;
	private float _scrollAreaTop;
	private float _contentHeight;
	private Vector2 _mousePos;
	private List<ClickRegion> _buttons = new();

	private List<JsonElement> _endpoints = new();
	private List<JsonElement> _tests = new();
	private int _selectedEndpointIdx = -1;
	private string _selectedTestId;
	private Rect _epDropdownScreenRect;

	private TextEdit _inputJson;
	private bool _skipWebhooks = true;
	private string _publishTarget = "live";

	private JsonElement? _lastResult;
	private string _lastError;
	private string _resultFilter = "all"; // "all", "failed", "passed"
	private bool _runAllFinished;
	private bool _reportReady;
	private string _reportPath;
	private List<JsonElement> _liveResults = new();
	private int _livePassedCount;
	private int _liveFailedCount;
	private CancellationTokenSource _cts;

	// Game values for smart input generation
	private JsonElement? _gameValuesRaw;

	private struct ClickRegion
	{
		public Rect Rect;
		public string Id;
		public Action OnClick;
	}

	public TestWindow()
	{
		Title = "Endpoint Tester";
		Size = new Vector2( 420, 640 );
		MinimumSize = new Vector2( 350, 400 );
		SyncToolConfig.Load();
		_publishTarget = NormalizePublishTarget( SyncToolConfig.PublishTarget );
		LoadData();
	}

	// TODO: WIP — re-enable menu entry when Endpoint Tester UI is ready
	// [Menu( "Editor", "Network Storage/Endpoint Tests" )]
	public static void OpenWindow()
	{
		var window = new TestWindow();
		window.Show();
	}

	private static string NormalizePublishTarget( string target ) => string.Equals( target, "next", StringComparison.OrdinalIgnoreCase ) ? "next" : "live";
	private static string TargetLabel( string target ) => NormalizePublishTarget( target ) == "next" ? "Staged/Main" : "Live";

	private void LoadData()
	{
		try
		{
			_endpoints = SyncToolConfig.LoadEndpoints();
			_tests = SyncToolConfig.LoadTests();
			_gameValuesRaw = LoadGameValues();
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[TestWindow] Load failed: {ex.Message}" );
		}

		if ( _inputJson == null )
		{
			_inputJson = new TextEdit( this );
			_inputJson.PlaceholderText = "Select an endpoint to auto-generate input";
			_inputJson.PlainText = "{}";
		}

		Update();
	}

	/// <summary>Load game_values to resolve IDs for smart input generation.</summary>
	private JsonElement? LoadGameValues()
	{
		var collections = SyncToolConfig.LoadCollections();
		foreach ( var (name, data) in collections )
		{
			if ( name != "game_values" ) continue;
			// Re-serialize Dictionary<string,object> → JsonElement
			var json = JsonSerializer.Serialize( data );
			return JsonSerializer.Deserialize<JsonElement>( json );
		}
		return null;
	}

	/// <summary>Get rows from a table in game_values.</summary>
	private List<JsonElement> GetTableRows( string tableId )
	{
		if ( !_gameValuesRaw.HasValue ) return new();

		var gv = _gameValuesRaw.Value;
		// Check tables array
		if ( gv.TryGetProperty( "tables", out var tables ) && tables.ValueKind == JsonValueKind.Array )
		{
			foreach ( var table in tables.EnumerateArray() )
			{
				var id = table.TryGetProperty( "id", out var tid ) ? tid.GetString() : "";
				if ( id != tableId ) continue;
				if ( table.TryGetProperty( "rows", out var rows ) && rows.ValueKind == JsonValueKind.Array )
					return rows.EnumerateArray().ToList();
			}
		}
		return new();
	}

	/// <summary>Try to find a real ID value for a field name from game values tables.</summary>
	private string FindIdValue( string fieldName )
	{
		var clean = fieldName.Replace( "_id", "" ).Replace( "Id", "" );
		// Try common table name patterns
		var tableGuesses = new[] { clean + "_types", clean + "s", clean };

		foreach ( var tableName in tableGuesses )
		{
			var rows = GetTableRows( tableName );
			if ( rows.Count == 0 ) continue;
			var first = rows[0];
			// Try common ID field patterns
			foreach ( var idField in new[] { fieldName, clean + "Id", clean + "_id", "id", clean } )
			{
				if ( first.TryGetProperty( idField, out var val ) && val.ValueKind == JsonValueKind.String )
					return val.GetString();
			}
		}

		// Special: factions
		if ( fieldName.Contains( "faction" ) )
		{
			var rows = GetTableRows( "factions" );
			if ( rows.Count > 0 )
			{
				var first = rows[0];
				if ( first.TryGetProperty( "factionId", out var fid ) ) return fid.GetString();
				if ( first.TryGetProperty( "id", out var id ) ) return id.GetString();
			}
		}

		return null;
	}

	/// <summary>Generate smart input JSON for an endpoint using schema + game values.</summary>
	private string GenerateSmartInput( JsonElement endpoint )
	{
		if ( !endpoint.TryGetProperty( "input", out var inputSchema ) ) return "{}";
		if ( !inputSchema.TryGetProperty( "properties", out var props ) ) return "{}";

		var input = new Dictionary<string, object>();
		foreach ( var prop in props.EnumerateObject() )
		{
			var type = prop.Value.TryGetProperty( "type", out var t ) ? t.GetString() : "string";

			if ( type == "string" )
			{
				// Try to resolve from game values
				var resolved = FindIdValue( prop.Name );
				if ( resolved != null )
				{
					input[prop.Name] = resolved;
				}
				else if ( prop.Value.TryGetProperty( "default", out var def ) )
				{
					input[prop.Name] = def.GetString();
				}
				else if ( prop.Value.TryGetProperty( "enum", out var enumVals ) && enumVals.ValueKind == JsonValueKind.Array )
				{
					var first = enumVals.EnumerateArray().FirstOrDefault();
					input[prop.Name] = first.ValueKind == JsonValueKind.String ? first.GetString() : "";
				}
				else
				{
					input[prop.Name] = prop.Name; // fallback: use field name as placeholder
				}
			}
			else if ( type == "number" )
			{
				if ( prop.Value.TryGetProperty( "default", out var def ) )
					input[prop.Name] = def.GetDouble();
				else if ( prop.Value.TryGetProperty( "min", out var min ) )
					input[prop.Name] = Math.Max( min.GetDouble(), 1.0 );
				else
					input[prop.Name] = 5.0;
			}
			else if ( type == "boolean" )
			{
				input[prop.Name] = prop.Value.TryGetProperty( "default", out var def ) && def.GetBoolean();
			}
		}

		return JsonSerializer.Serialize( input, new JsonSerializerOptions { WriteIndented = true } );
	}

	// ──────────────────────────────────────────────────────
	//  Paint
	// ──────────────────────────────────────────────────────

	protected override void OnPaint()
	{
		base.OnPaint();
		_buttons.Clear();
		var pad = 20f;
		var w = Width - pad * 2;
		var y = 38f;

		// ── Title ──
		Paint.SetDefaultFont( size: 14, weight: 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( pad, y, w, 24 ), "Endpoint Tester", TextFlag.LeftCenter );
		y += 32;

		if ( !SyncToolConfig.IsValid )
		{
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( new Color( 1f, 0.4f, 0.4f ) );
			Paint.DrawText( new Rect( pad, y, w, 16 ), "Configure credentials in Network Storage Setup first.", TextFlag.LeftCenter );
			_contentHeight = y + 20;
			return;
		}

		var targetColor = _publishTarget == "next" ? Color.Yellow : Color.Green;
		Paint.SetBrush( targetColor.WithAlpha( 0.10f ) );
		Paint.SetPen( targetColor.WithAlpha( 0.35f ) );
		Paint.DrawRect( new Rect( pad, y, w, 24 ), 4 );
		Paint.SetDefaultFont( size: 10, weight: 700 );
		Paint.SetPen( targetColor.WithAlpha( 0.95f ) );
		Paint.DrawText( new Rect( pad + 8, y, w - 16, 24 ), $"Tests run against: {TargetLabel( _publishTarget )}", TextFlag.LeftCenter );
		y += 32;

		_scrollAreaTop = y;
		y -= _scrollY;

		// ── QUICK TEST ──
		DrawSectionHeader( ref y, pad, w, "QUICK TEST" );

		// Endpoint dropdown button
		Paint.SetDefaultFont( size: 10 );
		Paint.SetPen( Color.White.WithAlpha( 0.6f ) );
		Paint.DrawText( new Rect( pad, y, 70, 20 ), "Endpoint:", TextFlag.LeftCenter );

		var epName = _selectedEndpointIdx >= 0 && _selectedEndpointIdx < _endpoints.Count
			? GetEpLabel( _endpoints[_selectedEndpointIdx] )
			: "Select endpoint...";

		var epRect = new Rect( pad + 72, y, w - 72, 22 );
		var epHovered = epRect.IsInside( _mousePos );
		Paint.SetBrush( Color.White.WithAlpha( epHovered ? 0.12f : 0.06f ) );
		Paint.SetPen( Color.Cyan.WithAlpha( epHovered ? 0.5f : 0.25f ) );
		Paint.DrawRect( epRect, 3 );
		Paint.SetDefaultFont( size: 10 );
		Paint.SetPen( _selectedEndpointIdx >= 0 ? Color.Cyan : Color.White.WithAlpha( 0.4f ) );
		Paint.DrawText( new Rect( epRect.Left + 8, epRect.Top, epRect.Width - 24, epRect.Height ), epName, TextFlag.LeftCenter );
		// Dropdown arrow
		Paint.SetPen( Color.White.WithAlpha( 0.4f ) );
		Paint.DrawText( new Rect( epRect.Right - 20, epRect.Top, 16, epRect.Height ), "v", TextFlag.Center );
		// Store screen-space position for OpenAt
		_epDropdownScreenRect = new Rect( epRect.Left, epRect.Bottom + _scrollY, epRect.Width, 0 );
		_buttons.Add( new ClickRegion { Rect = epRect, Id = "dropdown_ep", OnClick = ShowEndpointDropdown } );
		y += 28;

		// Input JSON
		Paint.SetPen( Color.White.WithAlpha( 0.6f ) );
		Paint.DrawText( new Rect( pad, y, w, 16 ), "Input JSON:", TextFlag.LeftCenter );
		y += 18;

		// Position TextEdit at the visual y (already scroll-adjusted)
		var inputScreenY = y;
		var inputH = 54f; // 3 lines tall
		_inputJson.Position = new Vector2( pad, inputScreenY );
		_inputJson.Size = new Vector2( w, inputH );
		_inputJson.Visible = inputScreenY > _scrollAreaTop && (inputScreenY + inputH) < Height;
		y += inputH + 6;

		// Skip webhooks
		DrawCheckbox( ref y, pad, w, "Skip Webhooks", _skipWebhooks, () => { _skipWebhooks = !_skipWebhooks; Update(); } );
		y += 6;

		// Run button
		DrawButton( ref y, pad, w, $"Run Quick Test ({TargetLabel( _publishTarget )})", Color.Cyan, "run_quick", _busy ? null : RunQuickTest );
		y += 8;

		DrawSeparator( ref y, w, pad );

		// ── SAVED TESTS ──
		DrawSectionHeader( ref y, pad, w, $"SAVED TESTS ({_tests.Count})" );

		if ( _tests.Count > 0 )
		{
			foreach ( var test in _tests )
			{
				DrawTestRow( ref y, pad, w, test );
			}

			y += 4;
			DrawButton( ref y, pad, w, $"Run All Tests ({TargetLabel( _publishTarget )})", new Color( 0.2f, 0.8f, 0.4f ), "run_all", _busy ? null : RunAllTests );
		}
		else
		{
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.White.WithAlpha( 0.3f ) );
			Paint.DrawText( new Rect( pad + 8, y, w, 16 ), "No test files in tests/", TextFlag.LeftCenter );
			y += 22;
		}

		y += 4;
		DrawButton( ref y, pad, w, $"Run All + Auto-Generate Preset Tests ({TargetLabel( _publishTarget )})", new Color( 0.6f, 0.4f, 1f ), "run_all_auto", _busy ? null : RunAllWithAutoGenerate );

		y += 8;
		DrawSeparator( ref y, w, pad );

		// ── RESULTS ──
		if ( _lastResult.HasValue || !string.IsNullOrEmpty( _lastError ) || _liveResults.Count > 0 )
		{
			DrawSectionHeader( ref y, pad, w, "RESULTS" );
			DrawResults( ref y, pad, w );
		}

		if ( !string.IsNullOrEmpty( _status ) )
		{
			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
			Paint.DrawText( new Rect( pad, y, w, 14 ), _status, TextFlag.LeftCenter );
			y += 20;
		}

		_contentHeight = y + _scrollY + 80;

		// Fixed header redraw
		Paint.SetBrush( new Color( 0.133f, 0.133f, 0.133f ) );
		Paint.ClearPen();
		Paint.DrawRect( new Rect( 0, 0, Width, _scrollAreaTop ) );
		Paint.SetDefaultFont( size: 14, weight: 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( pad, 38, w, 24 ), $"Endpoint Tester — {TargetLabel( _publishTarget )}", TextFlag.LeftCenter );
	}

	private void DrawTestRow( ref float y, float pad, float w, JsonElement test )
	{
		var testId = test.TryGetProperty( "id", out var tid ) ? tid.GetString() : "";
		var testName = test.TryGetProperty( "name", out var tn ) ? tn.GetString() : testId;
		var endpoint = test.TryGetProperty( "endpoint", out var ep ) ? ep.GetString() : "";
		var expect = test.TryGetProperty( "expect", out var ex ) && ex.TryGetProperty( "outcome", out var oc ) ? oc.GetString() : "pass";

		var rowRect = new Rect( pad, y, w, 34 );
		var rowHovered = rowRect.IsInside( _mousePos );
		var isSelected = testId == _selectedTestId;

		Paint.SetBrush( isSelected ? Color.Cyan.WithAlpha( 0.08f ) : rowHovered ? Color.White.WithAlpha( 0.04f ) : Color.Transparent );
		Paint.SetPen( isSelected ? Color.Cyan.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.05f ) );
		Paint.DrawRect( rowRect, 3 );

		Paint.SetDefaultFont( size: 10, weight: 600 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( pad + 6, y + 2, w - 80, 16 ), testName ?? "", TextFlag.LeftCenter );

		Paint.SetDefaultFont( size: 8 );
		Paint.SetPen( Color.White.WithAlpha( 0.4f ) );
		Paint.DrawText( new Rect( pad + 6, y + 17, w - 80, 14 ), endpoint, TextFlag.LeftCenter );

		var badgeColor = expect == "fail" ? new Color( 1f, 0.3f, 0.3f ) : expect == "any" ? new Color( 1f, 0.7f, 0.2f ) : new Color( 0.2f, 0.8f, 0.4f );
		Paint.SetBrush( badgeColor.WithAlpha( 0.15f ) );
		Paint.SetPen( badgeColor );
		var badgeRect = new Rect( pad + w - 70, y + 8, 60, 16 );
		Paint.DrawRect( badgeRect, 8 );
		Paint.SetDefaultFont( size: 7 );
		Paint.DrawText( badgeRect, expect, TextFlag.Center );

		_buttons.Add( new ClickRegion { Rect = rowRect, Id = $"run_test_{testId}", OnClick = () => RunSavedTest( testId ) } );
		y += 38;
	}

	private void DrawCheckbox( ref float y, float pad, float w, string label, bool value, Action toggle )
	{
		var boxRect = new Rect( pad, y, 16, 16 );
		Paint.SetBrush( value ? Color.Cyan.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.05f ) );
		Paint.SetPen( value ? Color.Cyan : Color.White.WithAlpha( 0.3f ) );
		Paint.DrawRect( boxRect, 2 );
		if ( value )
		{
			Paint.SetPen( Color.Cyan, 2f );
			Paint.DrawLine( new Vector2( pad + 3, y + 8 ), new Vector2( pad + 7, y + 12 ) );
			Paint.DrawLine( new Vector2( pad + 7, y + 12 ), new Vector2( pad + 13, y + 4 ) );
		}
		_buttons.Add( new ClickRegion { Rect = new Rect( pad, y, w, 16 ), Id = $"chk_{label}", OnClick = toggle } );
		Paint.SetDefaultFont( size: 10 );
		Paint.SetPen( Color.White.WithAlpha( 0.7f ) );
		Paint.DrawText( new Rect( pad + 22, y, w, 16 ), label, TextFlag.LeftCenter );
		y += 22;
	}

	private void DrawResults( ref float y, float pad, float w )
	{
		if ( !string.IsNullOrEmpty( _lastError ) )
		{
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( new Color( 1f, 0.3f, 0.3f ) );
			Paint.DrawText( new Rect( pad + 4, y, w - 8, 14 ), $"Error: {_lastError}", TextFlag.LeftCenter );
			y += 20;
			return;
		}

		// ── Run-All results (live or finished) ──
		if ( _liveResults.Count > 0 )
		{
			var total = _liveResults.Count;
			var passedCount = _livePassedCount;
			var failedCount = _liveFailedCount;

			// Summary line
			Paint.SetDefaultFont( size: 11, weight: 700 );
			Paint.SetPen( Color.White );
			var titleText = _runAllFinished ? $"Results: {total} tests" : $"Running... {total} tests so far";
			Paint.DrawText( new Rect( pad, y, w, 18 ), titleText, TextFlag.LeftCenter );
			y += 22;

			// Passed / Failed badges
			Paint.SetDefaultFont( size: 10, weight: 600 );
			Paint.SetBrush( new Color( 0.2f, 0.8f, 0.4f ).WithAlpha( 0.15f ) );
			Paint.SetPen( new Color( 0.2f, 0.8f, 0.4f ) );
			Paint.DrawRect( new Rect( pad, y, 80, 20 ), 4 );
			Paint.DrawText( new Rect( pad, y, 80, 20 ), $"Passed: {passedCount}", TextFlag.Center );

			Paint.SetBrush( new Color( 1f, 0.3f, 0.3f ).WithAlpha( 0.15f ) );
			Paint.SetPen( new Color( 1f, 0.3f, 0.3f ) );
			Paint.DrawRect( new Rect( pad + 88, y, 80, 20 ), 4 );
			Paint.DrawText( new Rect( pad + 88, y, 80, 20 ), $"Failed: {failedCount}", TextFlag.Center );

			if ( !_runAllFinished )
			{
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( Color.Yellow.WithAlpha( 0.7f ) );
				Paint.DrawText( new Rect( pad + 180, y, 100, 20 ), "Running...", TextFlag.LeftCenter );
			}
			y += 28;

			// Filter buttons + Report button
			var filterBtnW = 70f;
			DrawFilterButton( ref y, pad, filterBtnW, "All", "all", total );
			DrawFilterButton( ref y, pad + filterBtnW + 4, filterBtnW, "Failed", "failed", failedCount );
			DrawFilterButton( ref y, pad + (filterBtnW + 4) * 2, filterBtnW, "Passed", "passed", passedCount );

			// Report button — shows state
			var reportRect = new Rect( pad + w - 110, y, 106, 18 );
			if ( _reportReady )
			{
				DrawSmallBtn( reportRect, "Open Report", Color.Cyan, "open_report", () => {
					if ( !string.IsNullOrEmpty( _reportPath ) ) EditorUtility.OpenFile( _reportPath );
				} );
			}
			else
			{
				Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
				Paint.SetPen( Color.White.WithAlpha( 0.15f ) );
				Paint.DrawRect( reportRect, 4 );
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( Color.White.WithAlpha( 0.3f ) );
				Paint.DrawText( reportRect, _runAllFinished ? "Generating..." : "Running report...", TextFlag.Center );
			}
			y += 28;

			// Live test list
			foreach ( var test in _liveResults )
			{
				var testPassed = test.TryGetProperty( "passed", out var tp ) && tp.GetBoolean();
				if ( _resultFilter == "failed" && testPassed ) continue;
				if ( _resultFilter == "passed" && !testPassed ) continue;

				var testName = test.TryGetProperty( "name", out var tn ) ? tn.GetString() : "?";
				var testReason = test.TryGetProperty( "reason", out var tr ) ? tr.GetString() : null;
				var testTiming = test.TryGetProperty( "timing", out var ttm ) && ttm.TryGetProperty( "total", out var ttt ) ? ttt.GetDouble() : 0;

				var iconColor = testPassed ? new Color( 0.2f, 0.8f, 0.4f ) : new Color( 1f, 0.3f, 0.3f );
				Paint.SetDefaultFont( size: 9, weight: 700 );
				Paint.SetPen( iconColor );
				Paint.DrawText( new Rect( pad + 2, y, 12, 14 ), testPassed ? "+" : "x", TextFlag.Center );

				Paint.SetDefaultFont( size: 9, weight: 600 );
				Paint.SetPen( Color.White.WithAlpha( 0.9f ) );
				Paint.DrawText( new Rect( pad + 16, y, w - 80, 14 ), testName, TextFlag.LeftCenter );

				Paint.SetDefaultFont( size: 8 );
				Paint.SetPen( Color.White.WithAlpha( 0.3f ) );
				Paint.DrawText( new Rect( pad + w - 50, y, 46, 14 ), $"{testTiming}ms", TextFlag.RightCenter );

				y += 16;

				if ( !string.IsNullOrEmpty( testReason ) )
				{
					Paint.SetDefaultFont( size: 8 );
					Paint.SetPen( new Color( 1f, 0.3f, 0.3f ).WithAlpha( 0.8f ) );
					var reasonH = Math.Max( 12, (int)Math.Ceiling( testReason.Length / 50.0 ) * 11 );
					Paint.DrawText( new Rect( pad + 16, y, w - 20, reasonH ), testReason, TextFlag.LeftTop | TextFlag.WordWrap );
					y += reasonH + 4;
				}
				else
				{
					y += 4;
				}
			}
			return;
		}

		// ── Single test result ──
		if ( !_lastResult.HasValue ) return;
		var result = _lastResult.Value;

		// Expectation badge
		if ( result.TryGetProperty( "expectation", out var exp ) && exp.ValueKind == JsonValueKind.Object )
		{
			var passed = exp.TryGetProperty( "passed", out var p ) && p.GetBoolean();
			var badgeColor = passed ? new Color( 0.2f, 0.8f, 0.4f ) : new Color( 1f, 0.3f, 0.3f );
			Paint.SetBrush( badgeColor.WithAlpha( 0.15f ) );
			Paint.SetPen( badgeColor );
			Paint.DrawRect( new Rect( pad, y, 60, 18 ), 8 );
			Paint.SetDefaultFont( size: 9, weight: 700 );
			Paint.DrawText( new Rect( pad, y, 60, 18 ), passed ? "PASSED" : "FAILED", TextFlag.Center );
			if ( !passed && exp.TryGetProperty( "reason", out var reason ) )
			{
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( new Color( 1f, 0.3f, 0.3f ) );
				Paint.DrawText( new Rect( pad + 66, y, w - 66, 18 ), reason.GetString() ?? "", TextFlag.LeftCenter );
			}
			y += 24;
		}

		// Warnings
		if ( result.TryGetProperty( "warnings", out var warnings ) && warnings.ValueKind == JsonValueKind.Array )
		{
			foreach ( var warn in warnings.EnumerateArray() )
			{
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( new Color( 1f, 0.7f, 0.2f ) );
				var warnText = warn.GetString() ?? "";
				var warnH = Math.Max( 14, (int)Math.Ceiling( warnText.Length / 55.0 ) * 13 );
				Paint.DrawText( new Rect( pad + 4, y, w - 8, warnH ), $"! {warnText}", TextFlag.LeftTop | TextFlag.WordWrap );
				y += warnH + 4;
			}
		}

		// Steps
		if ( result.TryGetProperty( "steps", out var steps ) && steps.ValueKind == JsonValueKind.Array )
		{
			foreach ( var step in steps.EnumerateArray() )
			{
				var stepId = step.TryGetProperty( "id", out var sid ) ? sid.GetString() : "?";
				var stepType = step.TryGetProperty( "type", out var st ) ? st.GetString() : "?";
				var hasWarning = step.TryGetProperty( "warning", out _ );
				var passed = !step.TryGetProperty( "passed", out var sp ) || sp.ValueKind != JsonValueKind.False;

				var iconColor = hasWarning ? new Color( 1f, 0.7f, 0.2f ) : !passed ? new Color( 1f, 0.3f, 0.3f ) : new Color( 0.2f, 0.8f, 0.4f );
				Paint.SetDefaultFont( size: 9, weight: 700 );
				Paint.SetPen( iconColor );
				Paint.DrawText( new Rect( pad + 2, y, 12, 14 ), hasWarning ? "!" : !passed ? "x" : "+", TextFlag.Center );

				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( Color.White.WithAlpha( 0.8f ) );
				Paint.DrawText( new Rect( pad + 16, y, 100, 14 ), stepId, TextFlag.LeftCenter );
				Paint.SetPen( Color.White.WithAlpha( 0.4f ) );
				Paint.DrawText( new Rect( pad + 120, y, 70, 14 ), stepType, TextFlag.LeftCenter );

				if ( step.TryGetProperty( "result", out var res ) && res.ValueKind != JsonValueKind.Null )
				{
					if ( res.ValueKind == JsonValueKind.Object )
					{
						// For read steps (objects like player data), show key fields inline
						var keyValues = ExtractKeyFields( res );
						if ( !string.IsNullOrEmpty( keyValues ) )
						{
							Paint.SetPen( Color.Cyan.WithAlpha( 0.5f ) );
							Paint.SetDefaultFont( size: 8 );
							Paint.DrawText( new Rect( pad + 194, y, w - 194, 14 ), keyValues, TextFlag.LeftCenter );
						}
					}
					else if ( res.ValueKind != JsonValueKind.Array )
					{
						Paint.SetPen( Color.Cyan.WithAlpha( 0.7f ) );
						var resText = res.ToString();
						if ( resText.Length > 25 ) resText = resText[..25] + "...";
						Paint.DrawText( new Rect( pad + 194, y, w - 194, 14 ), $"= {resText}", TextFlag.LeftCenter );
					}
				}
				y += 22;

				// For read steps with object results, show important fields on separate lines
				if ( step.TryGetProperty( "result", out var readRes ) && readRes.ValueKind == JsonValueKind.Object && stepType == "read" )
				{
					var fields = GetRelevantReadFields( readRes );
					foreach ( var (fieldName, fieldValue) in fields )
					{
						Paint.SetDefaultFont( size: 8 );
						Paint.SetPen( Color.White.WithAlpha( 0.35f ) );
						Paint.DrawText( new Rect( pad + 28, y, 80, 12 ), fieldName, TextFlag.LeftCenter );
						Paint.SetPen( Color.Cyan.WithAlpha( 0.5f ) );
						var valText = fieldValue.Length > 40 ? fieldValue[..40] + "..." : fieldValue;
						Paint.DrawText( new Rect( pad + 110, y, w - 114, 12 ), valText, TextFlag.LeftCenter );
						y += 14;
					}
				}
			}
		}

		if ( result.TryGetProperty( "pendingWrites", out var pw ) && pw.ValueKind == JsonValueKind.Array && pw.GetArrayLength() > 0 )
		{
			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
			Paint.DrawText( new Rect( pad + 4, y, w, 14 ), $"{pw.GetArrayLength()} write operation(s) would execute", TextFlag.LeftCenter );
			y += 18;
		}

		// Open Output button
		y += 4;
		var copyRect = new Rect( pad, y, 100, 20 );
		DrawSmallBtn( copyRect, "Open Output", Color.White, "copy_output", CopyResultToClipboard );
		y += 26;
	}

	private void DrawFilterButton( ref float y, float x, float btnW, string label, string filter, int count )
	{
		var isActive = _resultFilter == filter;
		var rect = new Rect( x, y, btnW, 18 );
		var hovered = rect.IsInside( _mousePos );
		var color = isActive ? Color.Cyan : Color.White;

		Paint.SetBrush( color.WithAlpha( isActive ? 0.15f : hovered ? 0.08f : 0.03f ) );
		Paint.SetPen( color.WithAlpha( isActive ? 0.6f : 0.25f ) );
		Paint.DrawRect( rect, 3 );
		Paint.SetDefaultFont( size: 8, weight: isActive ? 700 : 400 );
		Paint.SetPen( color.WithAlpha( isActive ? 1f : 0.5f ) );
		Paint.DrawText( rect, $"{label} ({count})", TextFlag.Center );

		if ( !isActive )
			_buttons.Add( new ClickRegion { Rect = rect, Id = $"filter_{filter}", OnClick = () => { _resultFilter = filter; Update(); } } );
	}

	private void DrawSmallBtn( Rect rect, string label, Color color, string id, Action onClick )
	{
		var hovered = rect.IsInside( _mousePos );
		Paint.SetBrush( color.WithAlpha( hovered ? 0.1f : 0.04f ) );
		Paint.SetPen( color.WithAlpha( hovered ? 0.4f : 0.2f ) );
		Paint.DrawRect( rect, 3 );
		Paint.SetDefaultFont( size: 9 );
		Paint.SetPen( color.WithAlpha( 0.6f ) );
		Paint.DrawText( rect, $"  {label}", TextFlag.LeftCenter );
		_buttons.Add( new ClickRegion { Rect = rect, Id = id, OnClick = onClick } );
	}

	// Report generation moved to GenerateReportFile() called from RunAllTests

	private static void AppendTestMd( System.Text.StringBuilder sb, JsonElement test )
	{
		var name = test.TryGetProperty( "name", out var n ) ? n.GetString() : "?";
		var ep = test.TryGetProperty( "endpoint", out var e ) ? e.GetString() : "?";
		var method = test.TryGetProperty( "method", out var m ) ? m.GetString() : "POST";
		var ok = test.TryGetProperty( "passed", out var p ) && p.GetBoolean();
		var reason = test.TryGetProperty( "reason", out var r ) ? r.GetString() : null;

		sb.AppendLine( $"### {( ok ? "PASS" : "FAIL" )} - {name}" );
		sb.AppendLine( $"- **Endpoint:** `{method} {ep}`" );
		if ( !string.IsNullOrEmpty( reason ) ) sb.AppendLine( $"- **Reason:** {reason}" );
		if ( test.TryGetProperty( "input", out var inp ) )
		{
			sb.AppendLine( "- **Input:**" );
			sb.AppendLine( "```json" );
			sb.AppendLine( JsonSerializer.Serialize( inp, new JsonSerializerOptions { WriteIndented = true } ) );
			sb.AppendLine( "```" );
		}
		if ( test.TryGetProperty( "expect", out var exp ) )
			sb.AppendLine( $"- **Expected:** `{JsonSerializer.Serialize( exp )}`" );
		if ( test.TryGetProperty( "result", out var res ) )
		{
			sb.AppendLine( $"- **Result:** ok={( res.TryGetProperty( "ok", out var rok ) && rok.GetBoolean() )}, status={( res.TryGetProperty( "status", out var rs ) ? rs.GetInt32() : 0 )}" );
			if ( res.TryGetProperty( "body", out var body ) )
			{
				sb.AppendLine( "```json" );
				sb.AppendLine( JsonSerializer.Serialize( body, new JsonSerializerOptions { WriteIndented = true } ) );
				sb.AppendLine( "```" );
			}
		}
		if ( test.TryGetProperty( "warnings", out var ws ) && ws.ValueKind == JsonValueKind.Array && ws.GetArrayLength() > 0 )
		{
			sb.AppendLine( "- **Warnings:**" );
			foreach ( var w in ws.EnumerateArray() ) sb.AppendLine( $"  - {w.GetString()}" );
		}
		sb.AppendLine();
	}

	private void CopyResultToClipboard()
	{
		if ( !_lastResult.HasValue && string.IsNullOrEmpty( _lastError ) ) return;

		string text;
		if ( !string.IsNullOrEmpty( _lastError ) )
		{
			text = $"Error: {_lastError}";
		}
		else
		{
			text = JsonSerializer.Serialize( _lastResult.Value, new JsonSerializerOptions { WriteIndented = true } );
		}

		// Write to temp file and open it — s&box doesn't expose a clipboard API
		var tmpPath = Path.Combine( Path.GetTempPath(), "ns_test_output.json" );
		File.WriteAllText( tmpPath, text );
		EditorUtility.OpenFile( tmpPath );
		_status = $"Output saved to {tmpPath}";
		Update();
	}

	// ──────────────────────────────────────────────────────
	//  Drawing helpers
	// ──────────────────────────────────────────────────────

	private void DrawSectionHeader( ref float y, float pad, float w, string text )
	{
		Paint.SetDefaultFont( size: 9, weight: 700 );
		Paint.SetPen( Color.White.WithAlpha( 0.4f ) );
		Paint.DrawText( new Rect( pad, y, w, 14 ), text, TextFlag.LeftCenter );
		y += 20;
	}

	private void DrawSeparator( ref float y, float w, float pad )
	{
		Paint.SetPen( Color.White.WithAlpha( 0.08f ) );
		Paint.DrawLine( new Vector2( pad, y ), new Vector2( pad + w, y ) );
		y += 8;
	}

	private void DrawButton( ref float y, float pad, float w, string label, Color color, string id, Action onClick )
	{
		var rect = new Rect( pad, y, w, 26 );
		var hovered = rect.IsInside( _mousePos ) && onClick != null;
		Paint.SetBrush( color.WithAlpha( hovered ? 0.2f : 0.1f ) );
		Paint.SetPen( color.WithAlpha( hovered ? 0.6f : 0.3f ) );
		Paint.DrawRect( rect, 4 );
		Paint.SetDefaultFont( size: 10, weight: 600 );
		Paint.SetPen( onClick != null ? color : color.WithAlpha( 0.3f ) );
		Paint.DrawText( rect, _busy ? "Running..." : label, TextFlag.Center );
		if ( onClick != null )
			_buttons.Add( new ClickRegion { Rect = rect, Id = id, OnClick = onClick } );
		y += 30;
	}

	// ──────────────────────────────────────────────────────
	//  Endpoint Dropdown
	// ──────────────────────────────────────────────────────

	private void ShowEndpointDropdown()
	{
		if ( _endpoints.Count == 0 ) return;

		var menu = new Menu( this );
		for ( var i = 0; i < _endpoints.Count; i++ )
		{
			var idx = i;
			var ep = _endpoints[i];
			var label = GetEpLabel( ep );
			menu.AddOption( label, "signpost_split", () => SelectEndpoint( idx ) );
		}

		// Open below the dropdown button
		var screenPos = ScreenPosition + new Vector2( _epDropdownScreenRect.Left, _epDropdownScreenRect.Top );
		menu.OpenAt( screenPos );
	}

	private void SelectEndpoint( int idx )
	{
		_selectedEndpointIdx = idx;
		if ( idx >= 0 && idx < _endpoints.Count )
		{
			_inputJson.PlainText = GenerateSmartInput( _endpoints[idx] );
		}
		Update();
	}

	// ──────────────────────────────────────────────────────
	//  Actions
	// ──────────────────────────────────────────────────────

	private async void RunQuickTest()
	{
		if ( _busy || _selectedEndpointIdx < 0 || _selectedEndpointIdx >= _endpoints.Count ) return;

		var ep = _endpoints[_selectedEndpointIdx];
		var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
		if ( string.IsNullOrEmpty( slug ) ) return;

		_busy = true;
		_status = "Running quick test...";
		_lastResult = null;
		_lastError = null;
		Update();

		try
		{
			var inputObj = new Dictionary<string, object>
			{
				["slug"] = slug,
				["skipWebhooks"] = _skipWebhooks,
			};

			try
			{
				var parsed = JsonSerializer.Deserialize<JsonElement>( _inputJson.PlainText ?? "{}" );
				inputObj["input"] = parsed;
			}
			catch
			{
				_lastError = "Invalid JSON in input field.";
				return;
			}

			var body = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( inputObj ) );
			var resp = await SyncToolApi.RunTest( body, _publishTarget );
			if ( resp.HasValue )
				_lastResult = resp.Value;
			else
				_lastError = SyncToolApi.LastErrorMessage ?? "Request failed.";
		}
		catch ( Exception ex ) { _lastError = ex.Message; }
		finally { _busy = false; _status = ""; Update(); }
	}

	private async void RunSavedTest( string testId )
	{
		if ( _busy ) return;
		_busy = true;
		_selectedTestId = testId;
		_status = $"Running test...";
		_lastResult = null;
		_lastError = null;
		Update();

		try
		{
			var body = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { testId } ) );
			var resp = await SyncToolApi.RunTest( body, _publishTarget );
			if ( resp.HasValue ) _lastResult = resp.Value;
			else _lastError = SyncToolApi.LastErrorMessage ?? "Request failed.";
		}
		catch ( Exception ex ) { _lastError = ex.Message; }
		finally { _busy = false; _status = ""; Update(); }
	}

	private async void RunAllTests()
	{
		if ( _busy ) return;
		_cts?.Cancel();
		_cts = new CancellationTokenSource();
		var token = _cts.Token;

		_busy = true;
		_runAllFinished = false;
		_reportReady = false;
		_reportPath = null;
		_liveResults.Clear();
		_livePassedCount = 0;
		_liveFailedCount = 0;
		_lastResult = null;
		_lastError = null;
		_status = "Running tests...";
		Update();

		try
		{
			// First try running saved tests (server-side)
			_status = "Running saved tests...";
			Update();

			var allBody = JsonSerializer.Deserialize<JsonElement>( "{}" );
			var allResp = await SyncToolApi.RunAllTests( allBody, _publishTarget );

			if ( token.IsCancellationRequested ) { _busy = false; return; }

			if ( allResp.HasValue && allResp.Value.TryGetProperty( "results", out var savedResults ) && savedResults.ValueKind == JsonValueKind.Array )
			{
				foreach ( var test in savedResults.EnumerateArray() )
				{
					_liveResults.Add( test.Clone() );
					var passed = test.TryGetProperty( "passed", out var p ) && p.GetBoolean();
					if ( passed ) _livePassedCount++;
					else _liveFailedCount++;
				}
				Update();
			}

			// If no saved tests found, run a quick dry-run per endpoint
			if ( _liveResults.Count == 0 )
			{
				foreach ( var ep in _endpoints )
				{
					if ( token.IsCancellationRequested ) break;

					var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
					if ( string.IsNullOrEmpty( slug ) ) continue;

					_status = $"Testing {slug}...";
					Update();

					var input = JsonSerializer.Deserialize<JsonElement>( GenerateSmartInput( ep ) );
					var body = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { slug, input, skipWebhooks = true } ) );
					var resp = await SyncToolApi.RunTest( body, _publishTarget );

					if ( token.IsCancellationRequested ) break;

					if ( resp.HasValue )
					{
						var r = resp.Value;
						var hasRes = r.TryGetProperty( "result", out var qRes );
						var ok = hasRes && qRes.TryGetProperty( "ok", out var rok ) && rok.GetBoolean();
						var epName = ep.TryGetProperty( "name", out var n ) ? n.GetString() : slug;
						var epMethod = ep.TryGetProperty( "method", out var mm ) ? mm.GetString() : "POST";

						// Extract reason
						string reason = null;
						if ( !ok && hasRes && qRes.TryGetProperty( "body", out var qBody ) && qBody.TryGetProperty( "error", out var qErr ) && qErr.TryGetProperty( "message", out var qMsg ) )
							reason = qMsg.GetString();

						// Extract timing and warnings
						object timingObj = hasRes && qRes.TryGetProperty( "timing", out var qTiming ) ? (object)qTiming : null;
						object warningsObj = r.TryGetProperty( "warnings", out var qWarnings ) ? (object)qWarnings : null;

						var entry = new Dictionary<string, object>
						{
							["name"] = $"{epName} — Quick Test",
							["endpoint"] = slug,
							["method"] = epMethod,
							["passed"] = ok,
							["reason"] = reason,
							["timing"] = timingObj,
							["warnings"] = warningsObj,
						};
						var entryEl = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( entry ) );
						_liveResults.Add( entryEl );
						if ( ok ) _livePassedCount++; else _liveFailedCount++;
						Update();
					}
				}
			}
		}
		catch ( Exception ex ) { _lastError = ex.Message; }

		_runAllFinished = true;
		_status = "Generating report...";
		Update();

		// Generate report in background
		try
		{
			if ( _liveResults.Count > 0 )
			{
				GenerateReportFile();
				_reportReady = true;
			}
		}
		catch { }

		_busy = false;
		_status = "";
		Update();
	}

	private void GenerateReportFile()
	{
		if ( _liveResults.Count == 0 ) return;

		var sb = new System.Text.StringBuilder();
		sb.AppendLine( "# Endpoint Test Report" );
		sb.AppendLine( $"Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}" );
		sb.AppendLine( $"Publish target tested: {TargetLabel( _publishTarget )}" );
		sb.AppendLine();

		sb.AppendLine( "## Summary" );
		sb.AppendLine( $"- **Total:** {_liveResults.Count}" );
		sb.AppendLine( $"- **Passed:** {_livePassedCount}" );
		sb.AppendLine( $"- **Failed:** {_liveFailedCount}" );
		sb.AppendLine();

		var failed = _liveResults.Where( r => r.TryGetProperty( "passed", out var p ) && !p.GetBoolean() ).ToList();
		var passed = _liveResults.Where( r => r.TryGetProperty( "passed", out var p ) && p.GetBoolean() ).ToList();

		if ( failed.Count > 0 )
		{
			sb.AppendLine( $"## Failed ({failed.Count})" );
			sb.AppendLine();
			foreach ( var test in failed ) AppendTestMd( sb, test );
		}
		if ( passed.Count > 0 )
		{
			sb.AppendLine( $"## Passed ({passed.Count})" );
			sb.AppendLine();
			foreach ( var test in passed ) AppendTestMd( sb, test );
		}

		_reportPath = Path.Combine( Path.GetTempPath(), "ns_test_report.md" );
		File.WriteAllText( _reportPath, sb.ToString() );
	}

	private async void RunAllWithAutoGenerate()
	{
		if ( _busy ) return;
		_busy = true;
		_status = "Auto-generating tests for all endpoints...";
		_lastResult = null;
		_lastError = null;
		Update();

		try
		{
			// Step 1: Suggest tests for every endpoint
			var allSuggestions = new List<Dictionary<string, object>>();
			foreach ( var ep in _endpoints )
			{
				var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
				if ( string.IsNullOrEmpty( slug ) ) continue;

				_status = $"Generating tests for {slug}...";
				Update();

				var suggestBody = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { slug } ) );
				var resp = await SyncToolApi.SuggestTests( suggestBody );
				if ( resp.HasValue && resp.Value.TryGetProperty( "suggestions", out var sug ) && sug.ValueKind == JsonValueKind.Array )
				{
					foreach ( var s2 in sug.EnumerateArray() )
					{
						var dict = JsonSerializer.Deserialize<Dictionary<string, object>>( s2.GetRawText() );
						if ( dict != null ) allSuggestions.Add( dict );
					}
				}
			}

			// Step 2: Save all suggested tests
			if ( allSuggestions.Count > 0 )
			{
				_status = $"Saving {allSuggestions.Count} auto-generated tests...";
				Update();

				var saveBody = JsonSerializer.Deserialize<JsonElement>(
					JsonSerializer.Serialize( new { action = "save-tests-bulk", suggestions = allSuggestions } ) );
				// Use the save-tests-bulk via the management API
				var saveFmt = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( allSuggestions ) );
				var existing = await SyncToolApi.GetTests();
				// Push the suggestions as new tests
				var merged = new List<object>();
				if ( existing.HasValue )
				{
					var data = existing.Value;
					if ( data.TryGetProperty( "data", out var d ) ) data = d;
					if ( data.ValueKind == JsonValueKind.Array )
						foreach ( var t in data.EnumerateArray() )
							merged.Add( JsonSerializer.Deserialize<object>( t.GetRawText() ) );
				}
				merged.AddRange( allSuggestions );
				var pushBody = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( merged ) );
				await SyncToolApi.PushTests( pushBody );

				// Reload local data
				LoadData();
			}

			// Step 3: Run all tests
			_status = "Running all tests...";
			Update();

			var runBody = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { } ) );
			var runResp = await SyncToolApi.RunAllTests( runBody, _publishTarget );
			if ( runResp.HasValue ) _lastResult = runResp.Value;
			else _lastError = SyncToolApi.LastErrorMessage ?? "Request failed.";
		}
		catch ( Exception ex ) { _lastError = ex.Message; }
		finally { _busy = false; _status = ""; Update(); }
	}

	// ──────────────────────────────────────────────────────
	//  Input
	// ──────────────────────────────────────────────────────

	protected override void OnMousePress( MouseEvent e )
	{
		foreach ( var btn in _buttons )
		{
			if ( btn.Rect.IsInside( e.LocalPosition ) )
			{
				btn.OnClick?.Invoke();
				return;
			}
		}
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		_mousePos = e.LocalPosition;
		Update();
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		var maxScroll = Math.Max( 0, _contentHeight - (Height - _scrollAreaTop) );
		_scrollY = Math.Clamp( _scrollY + (e.Delta > 0 ? -40 : 40), 0, maxScroll );
		Update();
	}

	protected override bool OnClose()
	{
		_cts?.Cancel();
		return base.OnClose();
	}

	private string GetEpLabel( JsonElement ep )
	{
		var method = ep.TryGetProperty( "method", out var m ) ? m.GetString() : "POST";
		var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "?";
		return $"{method} {slug}";
	}

	/// <summary>Extract a compact summary of key fields from a read result for the inline display.</summary>
	private static string ExtractKeyFields( JsonElement obj )
	{
		var parts = new List<string>();
		// Show the most important game-relevant fields
		string[] priorityFields = { "currency", "xp", "level", "rank", "currentOreKg", "backpackCapacity" };
		foreach ( var field in priorityFields )
		{
			if ( obj.TryGetProperty( field, out var val ) && val.ValueKind == JsonValueKind.Number )
				parts.Add( $"{field}={val}" );
			if ( parts.Count >= 3 ) break;
		}
		return parts.Count > 0 ? string.Join( ", ", parts ) : "";
	}

	/// <summary>Get relevant fields from a read step result to show as sub-rows.</summary>
	private static List<(string Name, string Value)> GetRelevantReadFields( JsonElement obj )
	{
		var fields = new List<(string, string)>();
		// Important scalar fields
		string[] show = { "currency", "xp", "currentOreKg", "backpackCapacity", "rank", "playerName" };
		foreach ( var key in show )
		{
			if ( !obj.TryGetProperty( key, out var val ) ) continue;
			if ( val.ValueKind == JsonValueKind.Number )
				fields.Add( (key, val.ToString()) );
			else if ( val.ValueKind == JsonValueKind.String )
			{
				var s = val.GetString();
				if ( !string.IsNullOrEmpty( s ) ) fields.Add( (key, s) );
			}
		}
		// Show ores summary if present
		if ( obj.TryGetProperty( "ores", out var ores ) && ores.ValueKind == JsonValueKind.Object )
		{
			var oreCount = 0;
			var totalKg = 0.0;
			foreach ( var ore in ores.EnumerateObject() )
			{
				if ( ore.Value.ValueKind == JsonValueKind.Number )
				{
					var kg = ore.Value.GetDouble();
					if ( kg > 0 ) { oreCount++; totalKg += kg; }
				}
			}
			if ( oreCount > 0 )
				fields.Add( ("ores", $"{oreCount} types, {totalKg:F1}kg total") );
		}
		// Show owned vehicles count
		if ( obj.TryGetProperty( "ownedVehicles", out var vehicles ) && vehicles.ValueKind == JsonValueKind.Array )
			fields.Add( ("ownedVehicles", $"{vehicles.GetArrayLength()} owned") );
		// Show faction rep summary
		if ( obj.TryGetProperty( "factionRep", out var rep ) && rep.ValueKind == JsonValueKind.Object )
		{
			var repCount = 0;
			foreach ( var r in rep.EnumerateObject() )
				if ( r.Value.ValueKind == JsonValueKind.Number && r.Value.GetDouble() > 0 ) repCount++;
			if ( repCount > 0 )
				fields.Add( ("factionRep", $"{repCount} factions with rep") );
		}
		return fields;
	}
}