Editor/UI/ReviewDialog.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox.SecBox.Bridge;
using Sandbox.SecBox.Bridge.Dto;

namespace Sandbox.SecBox.UI;

// Entry point used by InstallHook / BootAudit. Marshals to the editor main
// thread via MainThread.Queue, then either creates a new ReviewWindow for
// the package or merges findings into an existing window (avoids spawning
// duplicate dialogs when the engine fires multiple install events for the
// same package across different tags).
public static class ReviewDialog
{
	// One open window per package ident. Engine fires install events with
	// gamemenu/game/local/tool tags - that's up to 4 dialogs without dedup.
	// Keyed by ident lower-case for stability.
	static readonly Dictionary<string, ReviewWindow> _openWindows =
		new(StringComparer.OrdinalIgnoreCase);

	public static void Show(
		string packageIdent,
		string contentHash,
		IList<Finding> findings,
		TrustStore store)
	{
		MainThread.Queue(() =>
		{
			try
			{
				if (_openWindows.TryGetValue(packageIdent, out var existing) && existing != null)
				{
					try
					{
						existing.MergeFindings(contentHash, findings);
						DiagnosticsLog.Trace($"merged {findings?.Count ?? 0} findings into open review window for {packageIdent}");
						return;
					}
					catch (Exception mergeEx)
					{
						DiagnosticsLog.Warn($"merge into existing window failed for {packageIdent}: {mergeEx.Message}; opening fresh");
						_openWindows.Remove(packageIdent);
					}
				}

				ReviewWindow window = null;
				window = new ReviewWindow(
					packageIdent, contentHash, findings, store,
					onDecision: decision =>
					{
						RecordDecision(store, packageIdent, contentHash, GetCurrentFindings(window, findings), decision);
						_openWindows.Remove(packageIdent);
					});

				window.Destroyed += () => _openWindows.Remove(packageIdent);
				_openWindows[packageIdent] = window;
				window.Show();
			}
			catch (Exception ex)
			{
				DiagnosticsLog.Error("ReviewWindow construction threw", ex);
				_openWindows.Remove(packageIdent);
				try
				{
					EditorUtility.DisplayDialog(
						$"secbox - {packageIdent}",
						BuildFallbackText(packageIdent, contentHash, findings),
						icon: "⚠️");
				}
				catch { }
			}
		});
	}

	// Pull the merged findings off the window when the user clicks a button -
	// otherwise we'd persist only the initial-scan subset.
	static IList<Finding> GetCurrentFindings(ReviewWindow window, IList<Finding> fallback)
	{
		try
		{
			var cur = window?.CurrentFindings;
			if (cur == null) return fallback;
			return cur as IList<Finding> ?? cur.ToList();
		}
		catch { return fallback; }
	}

	static string BuildFallbackText(string ident, string hash, IList<Finding> findings)
	{
		var critical = findings.Count(f => f.Severity == Severity.Critical);
		var high = findings.Count(f => f.Severity == Severity.High);
		var medium = findings.Count(f => f.Severity == Severity.Medium);
		var low = findings.Count(f => f.Severity == Severity.Low);

		return $"Package: {ident}\nHash: {hash[..16]}…\n\n"
			+ $"Findings: Critical={critical} High={high} Medium={medium} Low={low}\n\n"
			+ string.Join("\n", findings.OrderByDescending(f => f.Severity).Take(10)
				.Select(f => $"  [{f.Severity}] {f.RuleId} @ {Trunc(f.Location, 90)}"))
			+ "\n\nReview decision was not recorded - see secbox log.";
	}

	static string Trunc(string s, int max) =>
		string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + "…";

	static void RecordDecision(
		TrustStore store,
		string ident,
		string hash,
		IList<Finding> findings,
		Decision decision)
	{
		if (decision == Decision.NotReviewed) return; // user clicked "decide later"

		var critical = findings.Count(f => f.Severity == Severity.Critical);
		var high = findings.Count(f => f.Severity == Severity.High);
		var medium = findings.Count(f => f.Severity == Severity.Medium);
		var low = findings.Count(f => f.Severity == Severity.Low);

		store.Upsert(new TrustEntry
		{
			PackageIdent = ident,
			ContentHash = hash,
			Decision = decision,
			ReviewedAt = DateTime.UtcNow,
			CriticalCount = critical,
			HighCount = high,
			MediumCount = medium,
			LowCount = low,
			Notes = decision switch
			{
				Decision.TrustAlways => "User trusted via review dialog.",
				Decision.AllowOnce   => "User allowed for this session.",
				Decision.Block       => "User blocked via review dialog.",
				Decision.Quarantine  => "User quarantined via review dialog.",
				_                    => null,
			},
		});
		store.Save();

		DiagnosticsLog.Info($"user decision for {ident} ({hash[..16]}…): {decision}");
	}
}