Editor/TestResultsWindow.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>
/// Modal window showing live test results as they come in.
/// Shows each endpoint with pass/fail status, and a "View All Logs" button when done.
/// </summary>
public class TestResultsWindow : DockWindow
{
	private List<TestEntry> _entries = new();
	private bool _finished;
	private string _reportPath;
	private CancellationTokenSource _cts = new();
	private float _scrollY;
	private float _contentHeight;
	private float _scrollAreaTop;
	private Vector2 _mousePos;
	private List<ClickRegion> _buttons = new();
	private string _publishTarget = "live";

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

	public struct TestEntry
	{
		public string Name;
		public string Endpoint;
		public string Method;
		public bool Passed;
		public bool Deprecated;
		public string Reason;
		public double TimingMs;
		public string[] Warnings;
		public JsonElement? FullData;
	}

	public int TotalCount => _entries.Count;
	public int PassedCount => _entries.Count( e => e.Passed );
	public int FailedCount => _entries.Count( e => !e.Passed );

	public TestResultsWindow()
	{
		Title = "Test Results";
		Size = new Vector2( 620, 500 );
		MinimumSize = new Vector2( 450, 300 );
	}

	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";

	/// <summary>Open a new results window and run all tests, showing results as they arrive.</summary>
	public static TestResultsWindow OpenAndRun( string endpointFilter = null, string publishTarget = null )
	{
		SyncToolConfig.Load();
		var target = NormalizePublishTarget( publishTarget ?? SyncToolConfig.PublishTarget );
		var window = new TestResultsWindow { _publishTarget = target };
		window.Title = endpointFilter != null ? $"Testing {TargetLabel( target )}: {endpointFilter}" : $"Test Results — {TargetLabel( target )}";
		window.Show();
		_ = window.RunTests( endpointFilter );
		return window;
	}

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

	private async System.Threading.Tasks.Task RunTests( string endpointFilter )
	{
		var token = _cts.Token;
		try
		{
			if ( token.IsCancellationRequested ) return;

			// Use auto-test endpoint — no saved tests needed, generates and runs inline
			var body = endpointFilter != null
				? JsonSerializer.Serialize( new { slug = endpointFilter } )
				: "{}";
			var resp = await SyncToolApi.AutoTest( JsonSerializer.Deserialize<JsonElement>( body ), _publishTarget );
			if ( token.IsCancellationRequested ) return;

			if ( !resp.HasValue )
			{
				_entries.Add( new TestEntry { Name = "Request Failed", Endpoint = endpointFilter ?? "(all)", Passed = false, Reason = SyncToolApi.LastErrorMessage ?? "Unknown error" } );
				_finished = true;
				Update();
				return;
			}

			var result = resp.Value;

			if ( result.ValueKind != JsonValueKind.Object )
			{
				_entries.Add( new TestEntry
				{
					Name = "Malformed Response",
					Endpoint = endpointFilter ?? "(all)",
					Passed = false,
					Reason = $"Server returned top-level {result.ValueKind}, expected Object. Raw: {TruncateRaw( result )}",
				} );
			}
			// Single endpoint response has no "results" array — wrap it
			else if ( !result.TryGetProperty( "results", out var results ) )
			{
				TryParseEntry( result, endpointFilter, index: -1 );
			}
			else if ( results.ValueKind == JsonValueKind.Array )
			{
				int idx = 0;
				foreach ( var test in results.EnumerateArray() )
				{
					TryParseEntry( test, endpointFilter, idx );
					idx++;
				}
			}
			else
			{
				_entries.Add( new TestEntry
				{
					Name = "Malformed Response",
					Endpoint = endpointFilter ?? "(all)",
					Passed = false,
					Reason = $"'results' field is {results.ValueKind}, expected Array. Raw: {TruncateRaw( result )}",
				} );
			}

			// Generate report file
			_reportPath = GenerateReport();
		}
		catch ( Exception ex )
		{
			_entries.Add( new TestEntry
			{
				Name = "Runner Error",
				Endpoint = endpointFilter ?? "(all)",
				Passed = false,
				Reason = $"{ex.GetType().Name}: {ex.Message}",
			} );
		}

		_finished = true;
		Title = $"Test Results — {TargetLabel( _publishTarget )} — {PassedCount}/{TotalCount} passed";
		Update();
	}

	/// <summary>Parse one entry; on failure, record an entry with raw JSON context instead of crashing the whole batch.</summary>
	private void TryParseEntry( JsonElement test, string endpointFilter, int index )
	{
		try
		{
			// Defend against null or non-object entries before ParseAutoTestEntry runs.
			if ( test.ValueKind != JsonValueKind.Object )
			{
				var label = index >= 0 ? $"results[{index}]" : "result";
				_entries.Add( new TestEntry
				{
					Name = $"Malformed Entry ({label})",
					Endpoint = endpointFilter ?? "(unknown)",
					Passed = false,
					Reason = $"Entry is {test.ValueKind}, expected Object. Raw: {TruncateRaw( test )}",
				} );
				return;
			}

			ParseAutoTestEntry( test );
		}
		catch ( Exception ex )
		{
			// Salvage whatever identifying info we can from the raw element.
			var salvagedName = test.ValueKind == JsonValueKind.Object && test.TryGetProperty( "name", out var n ) && n.ValueKind == JsonValueKind.String ? n.GetString() : null;
			var salvagedEp = test.ValueKind == JsonValueKind.Object && test.TryGetProperty( "endpoint", out var ep ) && ep.ValueKind == JsonValueKind.String ? ep.GetString() : null;
			var salvagedMethod = test.ValueKind == JsonValueKind.Object && test.TryGetProperty( "method", out var m ) && m.ValueKind == JsonValueKind.String ? m.GetString() : "POST";
			var label = index >= 0 ? $"results[{index}]" : "result";

			_entries.Add( new TestEntry
			{
				Name = salvagedName ?? $"Parse Error ({label})",
				Endpoint = salvagedEp ?? endpointFilter ?? "(unknown)",
				Method = salvagedMethod,
				Passed = false,
				Reason = $"{ex.GetType().Name}: {ex.Message} — Raw: {TruncateRaw( test )}",
				FullData = test,
			} );
		}
	}

	private static string TruncateRaw( JsonElement el, int max = 240 )
	{
		try
		{
			var raw = el.GetRawText();
			if ( raw == null ) return "(null)";
			return raw.Length > max ? raw[..max] + "..." : raw;
		}
		catch ( Exception ex )
		{
			return $"(unreadable: {ex.Message})";
		}
	}

	private void ParseAutoTestEntry( JsonElement test )
	{
		var entry = new TestEntry
		{
			Name = ReadString( test, "name" ) ?? "(unnamed)",
			Endpoint = ReadString( test, "endpoint" ) ?? "(no endpoint)",
			Method = ReadString( test, "method" ) ?? "POST",
			Passed = ReadBool( test, "passed" ),
			Deprecated = ReadBool( test, "deprecated" ),
			FullData = test,
		};

		// Collect errors and warnings as the reason string
		var reasons = new List<string>();
		if ( test.TryGetProperty( "errors", out var errs ) && errs.ValueKind == JsonValueKind.Array )
		{
			foreach ( var e in errs.EnumerateArray() )
			{
				var s = e.ValueKind == JsonValueKind.String ? e.GetString() : e.GetRawText();
				if ( !string.IsNullOrEmpty( s ) ) reasons.Add( s );
			}
		}
		entry.Reason = reasons.Count > 0 ? string.Join( "; ", reasons ) : null;

		if ( test.TryGetProperty( "warnings", out var ws ) && ws.ValueKind == JsonValueKind.Array )
		{
			entry.Warnings = ws.EnumerateArray()
				.Select( w => w.ValueKind == JsonValueKind.String ? w.GetString() : w.GetRawText() )
				.Where( s => !string.IsNullOrEmpty( s ) )
				.ToArray();
		}

		if ( test.TryGetProperty( "result", out var res ) && res.ValueKind == JsonValueKind.Object
			&& res.TryGetProperty( "timing", out var tm ) && tm.ValueKind == JsonValueKind.Object
			&& tm.TryGetProperty( "total", out var tt ) && tt.ValueKind == JsonValueKind.Number )
		{
			entry.TimingMs = tt.GetDouble();
		}

		_entries.Add( entry );
	}

	private static string ReadString( JsonElement obj, string field )
	{
		if ( obj.ValueKind != JsonValueKind.Object ) return null;
		if ( !obj.TryGetProperty( field, out var v ) ) return null;
		return v.ValueKind == JsonValueKind.String ? v.GetString() : null;
	}

	private static bool ReadBool( JsonElement obj, string field )
	{
		if ( obj.ValueKind != JsonValueKind.Object ) return false;
		if ( !obj.TryGetProperty( field, out var v ) ) return false;
		return v.ValueKind == JsonValueKind.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 * 0.5f, 24 ), "Test Results", TextFlag.LeftCenter );

		// View All Logs button (disabled until finished)
		var logBtnW = 110f;
		var logBtnRect = new Rect( pad + w - logBtnW, y, logBtnW, 24 );
		if ( _finished )
		{
			DrawBtn( logBtnRect, "View All Logs", Color.Cyan, "view_logs", () => OpenReport() );
		}
		else
		{
			// Disabled state
			Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
			Paint.SetPen( Color.White.WithAlpha( 0.15f ) );
			Paint.DrawRect( logBtnRect, 4 );
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.White.WithAlpha( 0.25f ) );
			Paint.DrawText( logBtnRect, "View All Logs", TextFlag.Center );
		}
		y += 32;

		// Publish/test target confirmation
		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 ), $"Testing publish target: {TargetLabel( _publishTarget )}", TextFlag.LeftCenter );
		y += 32;

		// Summary badges
		if ( _entries.Count > 0 )
		{
			Paint.SetDefaultFont( size: 11, weight: 700 );

			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, 90, 22 ), 4 );
			Paint.DrawText( new Rect( pad, y, 90, 22 ), $"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 + 98, y, 90, 22 ), 4 );
			Paint.DrawText( new Rect( pad + 98, y, 90, 22 ), $"Failed: {FailedCount}", TextFlag.Center );

			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.White.WithAlpha( 0.4f ) );
			Paint.DrawText( new Rect( pad + 200, y, 100, 22 ), $"{TotalCount} total", TextFlag.LeftCenter );

			if ( !_finished )
			{
				Paint.SetPen( Color.Yellow.WithAlpha( 0.7f ) );
				Paint.DrawText( new Rect( pad + w - 100, y, 100, 22 ), "Running...", TextFlag.RightCenter );
			}
			y += 28;

			// "View all failed test results" button
			if ( FailedCount > 0 )
			{
				var failBtnW = 180f;
				var failBtnRect = new Rect( pad, y, failBtnW, 22 );
				if ( _finished )
				{
					DrawBtn( failBtnRect, $"View Failed Results ({FailedCount})", new Color( 1f, 0.3f, 0.3f ), "view_failed", OpenFailedReport );
				}
				else
				{
					Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
					Paint.SetPen( Color.White.WithAlpha( 0.15f ) );
					Paint.DrawRect( failBtnRect, 4 );
					Paint.SetDefaultFont( size: 9 );
					Paint.SetPen( Color.White.WithAlpha( 0.25f ) );
					Paint.DrawText( failBtnRect, $"View Failed ({FailedCount})...", TextFlag.Center );
				}
				y += 28;
			}
		}
		else if ( !_finished )
		{
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.White.WithAlpha( 0.4f ) );
			Paint.DrawText( new Rect( pad, y, w, 16 ), "Running tests...", TextFlag.LeftCenter );
			y += 24;
		}

		_scrollAreaTop = y;
		y -= _scrollY;

		// Separator
		Paint.SetPen( Color.White.WithAlpha( 0.08f ) );
		Paint.DrawLine( new Vector2( pad, y ), new Vector2( pad + w, y ) );
		y += 8;

		// Test entries
		foreach ( var entry in _entries )
		{
			if ( y > Height + 20 ) break; // skip offscreen
			if ( y + 40 > _scrollAreaTop ) // only draw visible
			{
				DrawTestEntry( ref y, pad, w, entry );
			}
			else
			{
				y += entry.Reason != null ? 46 : 30;
			}
		}

		_contentHeight = y + _scrollY + 40;

		// Redraw fixed header
		Paint.SetBrush( new Color( 0.133f, 0.133f, 0.133f ) );
		Paint.ClearPen();
		Paint.DrawRect( new Rect( 0, 0, Width, _scrollAreaTop ) );

		// Re-draw header elements
		y = 38f;
		Paint.SetDefaultFont( size: 14, weight: 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( pad, y, w * 0.5f, 24 ), "Test Results", TextFlag.LeftCenter );

		if ( _finished )
			DrawBtn( logBtnRect, "View All Logs", Color.Cyan, "view_logs", () => OpenReport() );
		else
		{
			Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
			Paint.SetPen( Color.White.WithAlpha( 0.15f ) );
			Paint.DrawRect( logBtnRect, 4 );
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.White.WithAlpha( 0.25f ) );
			Paint.DrawText( logBtnRect, "View All Logs", TextFlag.Center );
		}

		y += 32;
		if ( _entries.Count > 0 )
		{
			Paint.SetDefaultFont( size: 11, weight: 700 );
			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, 90, 22 ), 4 );
			Paint.DrawText( new Rect( pad, y, 90, 22 ), $"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 + 98, y, 90, 22 ), 4 );
			Paint.DrawText( new Rect( pad + 98, y, 90, 22 ), $"Failed: {FailedCount}", TextFlag.Center );
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.White.WithAlpha( 0.4f ) );
			Paint.DrawText( new Rect( pad + 200, y, 100, 22 ), $"{TotalCount} total", TextFlag.LeftCenter );
			if ( !_finished )
			{
				Paint.SetPen( Color.Yellow.WithAlpha( 0.7f ) );
				Paint.DrawText( new Rect( pad + w - 100, y, 100, 22 ), "Running...", TextFlag.RightCenter );
			}
			y += 28;
			if ( FailedCount > 0 && _finished )
			{
				var failBtnRect2 = new Rect( pad, y, 180, 22 );
				DrawBtn( failBtnRect2, $"View Failed Results ({FailedCount})", new Color( 1f, 0.3f, 0.3f ), "view_failed", OpenFailedReport );
			}
		}
	}

	private void DrawTestEntry( ref float y, float pad, float w, TestEntry entry )
	{
		var rowH = 28f;

		// Icon
		if ( entry.Deprecated )
		{
			Paint.SetDefaultFont( size: 10, weight: 700 );
			Paint.SetPen( Color.White.WithAlpha( 0.25f ) );
			Paint.DrawText( new Rect( pad, y, 16, rowH ), "-", TextFlag.Center );
		}
		else
		{
			var iconColor = entry.Passed ? new Color( 0.2f, 0.8f, 0.4f ) : new Color( 1f, 0.3f, 0.3f );
			Paint.SetDefaultFont( size: 10, weight: 700 );
			Paint.SetPen( iconColor );
			Paint.DrawText( new Rect( pad, y, 16, rowH ), entry.Passed ? "+" : "x", TextFlag.Center );
		}

		// Name
		Paint.SetDefaultFont( size: 10, weight: 600 );
		Paint.SetPen( entry.Deprecated ? Color.White.WithAlpha( 0.35f ) : Color.White.WithAlpha( 0.9f ) );
		var nameText = entry.Name;
		Paint.DrawText( new Rect( pad + 20, y, w - 170, rowH ), nameText ?? "", TextFlag.LeftCenter );

		// Deprecated badge
		if ( entry.Deprecated )
		{
			var nameW = Paint.MeasureText( nameText ?? "" ).x;
			var depX = pad + 20 + nameW + 6;
			Paint.SetDefaultFont( size: 7 );
			var depText = "deprecated";
			var depTextW = Paint.MeasureText( depText ).x + 8;
			var depRect = new Rect( depX, y + ( rowH - 14 ) / 2, depTextW, 14 );
			Paint.SetBrush( new Color( 0.96f, 0.62f, 0.04f, 0.12f ) );
			Paint.SetPen( new Color( 0.96f, 0.62f, 0.04f, 0.25f ) );
			Paint.DrawRect( depRect, 3 );
			Paint.SetPen( new Color( 0.96f, 0.62f, 0.04f, 0.6f ) );
			Paint.DrawText( depRect, depText, TextFlag.Center );
		}

		// Endpoint badge
		Paint.SetDefaultFont( size: 8 );
		Paint.SetPen( Color.White.WithAlpha( entry.Deprecated ? 0.15f : 0.3f ) );
		Paint.DrawText( new Rect( pad + w - 150, y, 90, rowH ), entry.Endpoint, TextFlag.LeftCenter );

		// Timing
		Paint.DrawText( new Rect( pad + w - 55, y, 50, rowH ), entry.Deprecated ? "skipped" : $"{entry.TimingMs}ms", TextFlag.RightCenter );

		// Download log button (per entry)
		if ( entry.FullData.HasValue )
		{
			var dlRect = new Rect( pad + w - 2, y + 4, 14, 18 );
			var dlHovered = dlRect.IsInside( _mousePos );
			Paint.SetPen( Color.White.WithAlpha( dlHovered ? 0.6f : 0.25f ) );
			Paint.SetDefaultFont( size: 10 );
			Paint.DrawText( dlRect, ">", TextFlag.Center );
			var captured = entry;
			_buttons.Add( new ClickRegion { Rect = dlRect, Id = $"dl_{entry.Name}", OnClick = () => OpenSingleLog( captured ) } );
		}

		y += rowH;

		// Failure reason
		if ( !string.IsNullOrEmpty( entry.Reason ) )
		{
			Paint.SetDefaultFont( size: 8 );
			Paint.SetPen( new Color( 1f, 0.3f, 0.3f ).WithAlpha( 0.7f ) );
			var reasonH = Math.Max( 12, (int)Math.Ceiling( entry.Reason.Length / 60.0 ) * 11 );
			Paint.DrawText( new Rect( pad + 20, y, w - 24, reasonH ), entry.Reason, TextFlag.LeftTop | TextFlag.WordWrap );
			y += reasonH + 4;
		}

		// Warnings count
		if ( entry.Warnings != null && entry.Warnings.Length > 0 )
		{
			Paint.SetDefaultFont( size: 8 );
			Paint.SetPen( new Color( 1f, 0.7f, 0.2f ).WithAlpha( 0.7f ) );
			Paint.DrawText( new Rect( pad + 20, y, w, 12 ), $"{entry.Warnings.Length} warning(s)", TextFlag.LeftCenter );
			y += 14;
		}

		y += 2;
	}

	private void DrawBtn( Rect rect, string label, Color color, string id, Action onClick )
	{
		var hovered = rect.IsInside( _mousePos );
		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( color );
		Paint.DrawText( rect, label, TextFlag.Center );
		_buttons.Add( new ClickRegion { Rect = rect, Id = id, OnClick = onClick } );
	}

	// ──────────────────────────────────────────────────────
	//  Report generation
	// ──────────────────────────────────────────────────────

	private string GenerateReport()
	{
		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:** {TotalCount}" );
		sb.AppendLine( $"- **Passed:** {PassedCount}" );
		sb.AppendLine( $"- **Failed:** {FailedCount}" );
		sb.AppendLine();

		var failed = _entries.Where( e => !e.Passed ).ToList();
		var passed = _entries.Where( e => e.Passed ).ToList();

		if ( failed.Count > 0 )
		{
			sb.AppendLine( $"## Failed ({failed.Count})" );
			sb.AppendLine();
			foreach ( var e in failed ) AppendEntryMd( sb, e );
		}

		if ( passed.Count > 0 )
		{
			sb.AppendLine( $"## Passed ({passed.Count})" );
			sb.AppendLine();
			foreach ( var e in passed ) AppendEntryMd( sb, e );
		}

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

	private void AppendEntryMd( System.Text.StringBuilder sb, TestEntry entry )
	{
		var name = string.IsNullOrWhiteSpace( entry.Name ) ? "(unnamed)" : entry.Name;
		var method = string.IsNullOrWhiteSpace( entry.Method ) ? "POST" : entry.Method;
		var endpoint = string.IsNullOrWhiteSpace( entry.Endpoint ) ? "(endpoint missing)" : entry.Endpoint;
		sb.AppendLine( $"### {( entry.Passed ? "PASS" : "FAIL" )} — {name}" );
		sb.AppendLine( $"- **Endpoint:** `{method} {endpoint}`" );
		sb.AppendLine( $"- **Timing:** {entry.TimingMs}ms" );
		if ( !string.IsNullOrEmpty( entry.Reason ) )
			sb.AppendLine( $"- **Reason:** {entry.Reason}" );

		if ( entry.FullData.HasValue && entry.FullData.Value.ValueKind == JsonValueKind.Object )
		{
			var test = entry.FullData.Value;
			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 ) && res.ValueKind == JsonValueKind.Object )
			{
				var resOk = res.TryGetProperty( "ok", out var rok ) && rok.ValueKind == JsonValueKind.True;
				var resStatus = res.TryGetProperty( "status", out var rs ) && rs.ValueKind == JsonValueKind.Number ? rs.GetInt32() : 0;
				sb.AppendLine( $"- **Result:** ok={resOk}, status={resStatus}" );
				if ( res.TryGetProperty( "body", out var body ) )
				{
					sb.AppendLine( "```json" );
					sb.AppendLine( JsonSerializer.Serialize( body, new JsonSerializerOptions { WriteIndented = true } ) );
					sb.AppendLine( "```" );
				}
			}
			if ( test.TryGetProperty( "steps", out var steps ) && steps.ValueKind == JsonValueKind.Array && steps.GetArrayLength() > 0 )
			{
				sb.AppendLine( "- **Steps:**" );
				foreach ( var step in steps.EnumerateArray() )
				{
					if ( step.ValueKind != JsonValueKind.Object ) continue;
					var sid = step.TryGetProperty( "id", out var si ) && si.ValueKind == JsonValueKind.String ? si.GetString() : "?";
					var stype = step.TryGetProperty( "type", out var st ) && st.ValueKind == JsonValueKind.String ? st.GetString() : "?";
					var spass = !step.TryGetProperty( "passed", out var sp ) || sp.ValueKind != JsonValueKind.False;
					sb.AppendLine( $"  - {( spass ? "+" : "x" )} `{sid}` ({stype})" );
				}
			}
		}

		if ( entry.Warnings != null && entry.Warnings.Length > 0 )
		{
			sb.AppendLine( "- **Warnings:**" );
			foreach ( var w in entry.Warnings )
				sb.AppendLine( $"  - {w}" );
		}
		sb.AppendLine();
	}

	private void OpenReport()
	{
		if ( string.IsNullOrEmpty( _reportPath ) )
			_reportPath = GenerateReport();
		EditorUtility.OpenFile( _reportPath );
	}

	private void OpenFailedReport()
	{
		var failed = _entries.Where( e => !e.Passed ).ToList();
		if ( failed.Count == 0 ) return;

		var sb = new System.Text.StringBuilder();
		sb.AppendLine( "# Failed Test Results — Full Context" );
		sb.AppendLine( $"Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}" );
		sb.AppendLine( $"Publish target tested: {TargetLabel( _publishTarget )}" );
		sb.AppendLine( $"Failed: {failed.Count} / {_entries.Count} total" );
		sb.AppendLine();

		foreach ( var entry in failed )
		{
			sb.AppendLine( "—-" );
			sb.AppendLine();
			sb.AppendLine( $"## FAIL — {( string.IsNullOrWhiteSpace( entry.Name ) ? "(unnamed)" : entry.Name )}" );
			sb.AppendLine();
			sb.AppendLine( $"| Field | Value |" );
			sb.AppendLine( $"|———-|———-|" );
			var methodStr = string.IsNullOrWhiteSpace( entry.Method ) ? "POST" : entry.Method;
			var endpointStr = string.IsNullOrWhiteSpace( entry.Endpoint ) ? "(endpoint missing)" : entry.Endpoint;
			sb.AppendLine( $"| **Endpoint** | `{methodStr} {endpointStr}` |" );
			sb.AppendLine( $"| **Timing** | {entry.TimingMs}ms |" );
			sb.AppendLine( $"| **Reason** | {entry.Reason ?? "—"} |" );
			sb.AppendLine();

			if ( entry.FullData.HasValue && entry.FullData.Value.ValueKind == JsonValueKind.Object )
			{
				var test = entry.FullData.Value;

				// Test ID
				if ( test.TryGetProperty( "testId", out var tid ) )
					sb.AppendLine( $"**Test ID:** `{tid}`" );

				// Input
				if ( test.TryGetProperty( "input", out var inp ) )
				{
					sb.AppendLine();
					sb.AppendLine( "### Input" );
					sb.AppendLine( "```json" );
					sb.AppendLine( JsonSerializer.Serialize( inp, new JsonSerializerOptions { WriteIndented = true } ) );
					sb.AppendLine( "```" );
				}

				// Expected
				if ( test.TryGetProperty( "expect", out var exp ) )
				{
					sb.AppendLine();
					sb.AppendLine( "### Expected Outcome" );
					sb.AppendLine( "```json" );
					sb.AppendLine( JsonSerializer.Serialize( exp, new JsonSerializerOptions { WriteIndented = true } ) );
					sb.AppendLine( "```" );
				}

				// Mock Data
				if ( test.TryGetProperty( "mockData", out var mock ) && mock.ValueKind == JsonValueKind.Object )
				{
					sb.AppendLine();
					sb.AppendLine( "### Mock Player Data" );
					sb.AppendLine( "```json" );
					sb.AppendLine( JsonSerializer.Serialize( mock, new JsonSerializerOptions { WriteIndented = true } ) );
					sb.AppendLine( "```" );
				}

				// Result
				if ( test.TryGetProperty( "result", out var res ) && res.ValueKind == JsonValueKind.Object )
				{
					sb.AppendLine();
					sb.AppendLine( "### Actual Result" );
					var resOk = res.TryGetProperty( "ok", out var rok ) && rok.ValueKind == JsonValueKind.True;
					var resStatus = res.TryGetProperty( "status", out var rs ) && rs.ValueKind == JsonValueKind.Number ? rs.GetInt32() : 0;
					sb.AppendLine( $"- **ok:** {resOk}" );
					sb.AppendLine( $"- **status:** {resStatus}" );
					if ( res.TryGetProperty( "body", out var body ) )
					{
						sb.AppendLine( "- **body:**" );
						sb.AppendLine( "```json" );
						sb.AppendLine( JsonSerializer.Serialize( body, new JsonSerializerOptions { WriteIndented = true } ) );
						sb.AppendLine( "```" );
					}
				}

				// Steps
				if ( test.TryGetProperty( "steps", out var steps ) && steps.ValueKind == JsonValueKind.Array && steps.GetArrayLength() > 0 )
				{
					sb.AppendLine();
					sb.AppendLine( "### Step Trace" );
					sb.AppendLine( "| Step | Type | Status | Result |" );
					sb.AppendLine( "|———|———|————|————|" );
					foreach ( var step in steps.EnumerateArray() )
					{
						if ( step.ValueKind != JsonValueKind.Object )
						{
							sb.AppendLine( $"| `(non-object)` | — | — | {TruncateRaw( step, 60 )} |" );
							continue;
						}
						var sid = step.TryGetProperty( "id", out var si ) && si.ValueKind == JsonValueKind.String ? si.GetString() : "?";
						var stype = step.TryGetProperty( "type", out var st ) && st.ValueKind == JsonValueKind.String ? st.GetString() : "?";
						var spass = !step.TryGetProperty( "passed", out var sp ) || sp.ValueKind != JsonValueKind.False;
						var swarn = step.TryGetProperty( "warning", out var sw ) && sw.ValueKind == JsonValueKind.String ? sw.GetString() : null;
						var status = swarn != null ? "Warning" : spass ? "OK" : "FAIL";
						var sresult = "";
						if ( step.TryGetProperty( "result", out var sr ) )
						{
							if ( sr.ValueKind == JsonValueKind.Number || sr.ValueKind == JsonValueKind.String || sr.ValueKind == JsonValueKind.True || sr.ValueKind == JsonValueKind.False )
								sresult = sr.ToString();
							else if ( sr.ValueKind == JsonValueKind.Null )
								sresult = "null";
							else if ( sr.ValueKind == JsonValueKind.Object )
								sresult = $"(object, {sr.EnumerateObject().Count()} fields)";
						}
						sb.AppendLine( $"| `{sid}` | {stype} | {status} | {sresult} |" );
					}
				}

				// Pending Writes
				if ( test.TryGetProperty( "pendingWrites", out var pw ) && pw.ValueKind == JsonValueKind.Array && pw.GetArrayLength() > 0 )
				{
					sb.AppendLine();
					sb.AppendLine( "### Pending Writes (would execute)" );
					sb.AppendLine( "```json" );
					sb.AppendLine( JsonSerializer.Serialize( pw, new JsonSerializerOptions { WriteIndented = true } ) );
					sb.AppendLine( "```" );
				}
			}

			// Warnings
			if ( entry.Warnings != null && entry.Warnings.Length > 0 )
			{
				sb.AppendLine();
				sb.AppendLine( "### Warnings" );
				foreach ( var w in entry.Warnings )
					sb.AppendLine( $"- {w}" );
			}

			// Full raw JSON
			if ( entry.FullData.HasValue )
			{
				sb.AppendLine();
				sb.AppendLine( "<details><summary>Full Raw JSON</summary>" );
				sb.AppendLine();
				sb.AppendLine( "```json" );
				sb.AppendLine( JsonSerializer.Serialize( entry.FullData.Value, new JsonSerializerOptions { WriteIndented = true } ) );
				sb.AppendLine( "```" );
				sb.AppendLine( "</details>" );
			}

			sb.AppendLine();
		}

		var path = Path.Combine( Path.GetTempPath(), "ns_failed_tests.md" );
		File.WriteAllText( path, sb.ToString() );
		EditorUtility.OpenFile( path );
	}

	private void OpenSingleLog( TestEntry entry )
	{
		if ( !entry.FullData.HasValue ) return;
		var json = JsonSerializer.Serialize( entry.FullData.Value, new JsonSerializerOptions { WriteIndented = true } );
		var safeName = (entry.Name ?? "test").Replace( " ", "_" ).Replace( "/", "_" );
		var path = Path.Combine( Path.GetTempPath(), $"ns_test_{safeName}.json" );
		File.WriteAllText( path, json );
		EditorUtility.OpenFile( path );
	}

	// ──────────────────────────────────────────────────────
	//  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();
	}
}