Editor/Canvas/LiveTreeMirror.cs
using System;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Projection;
using Grains.RazorDesigner.Serialization;
using Sandbox.UI;

namespace Grains.RazorDesigner.Canvas;

public sealed class LiveTreeMirror
{
	private const string LogPrefix = "[Grains.RazorDesigner]";
	// Class on canvas root drives the chrome-hidden CSS branch in PreviewMarkerRules.
	internal const string ChromeHiddenClass = "chrome-hidden";

	private readonly DesignerDocument _document;
	private readonly Func<Panel> _getCanvasRoot;
	private readonly Func<bool> _getChromeHidden;

	/// <summary>The canvas Root panel the mirror last populated into (== RootRecord.LivePanel). Null before the first Repopulate.</summary>
	public Panel CurrentRoot => _document.RootRecord.LivePanel;

	public LiveTreeMirror(
		DesignerDocument document,
		Func<Panel> getCanvasRoot,
		Func<bool> getChromeHidden )
	{
		_document = document;
		_getCanvasRoot = getCanvasRoot;
		_getChromeHidden = getChromeHidden;
		Log.Info( $"{LogPrefix} LiveTreeMirror ctor" );
	}

	public bool Repopulate()
	{
		var root = _getCanvasRoot();
		if ( root is null || !root.IsValid )
		{
			Log.Warning( $"{LogPrefix} RepopulateMirror: root not valid; skipping {_document.RootRecord.Children.Count} root child(ren)" );
			return false;
		}

		// Perf probe (grd-pewf): full wipe+rebuild is an event path (load / hotreload / undo), log every time.
		var probeSw = System.Diagnostics.Stopwatch.StartNew();

		_document.RootRecord.LivePanel = root;
		root.AddClass( "root" );
		root.SetClass( ChromeHiddenClass, _getChromeHidden() );
		root.DeleteChildren( immediate: true );
		var probeWipeMs = probeSw.Elapsed.TotalMilliseconds;

		Log.Info( $"{LogPrefix} RepopulateMirror: wiped subtree, wired RootRecord.LivePanel; re-creating {_document.RootRecord.Children.Count} root child(ren)" );

		var ctx = new ProjectionContext( PreviewTheme.Default, ForPreview: true );
		foreach ( var r in _document.RootRecord.Children )
		{
			Applier.BuildPreview( new RecordNode( r ), root, ctx, OnNodeBuilt );
		}

		Log.Info( $"{LogPrefix} probe: Repopulate took {probeSw.Elapsed.TotalMilliseconds:F3}ms = wipe {probeWipeMs:F3}ms + build {probeSw.Elapsed.TotalMilliseconds - probeWipeMs:F3}ms ({_document.RootRecord.Children.Count} root child(ren))" );
		return true;
	}

	public void Reapply( ControlRecord record )
	{
		if ( record?.LivePanel is null || !record.LivePanel.IsValid ) return;
		var ctx = new ProjectionContext( PreviewTheme.Default, ForPreview: true );
		Applier.ReapplyContent( new RecordNode( record ), record.LivePanel, ctx );
	}

	public bool MirrorInserted( ControlRecord record, ControlRecord parent )
	{
		if ( record is null || parent is null ) return false;
		var liveParent = parent.LivePanel;
		if ( liveParent is null || !liveParent.IsValid ) return false;

		var ctx = new ProjectionContext( PreviewTheme.Default, ForPreview: true );
		Applier.BuildPreview( new RecordNode( record ), liveParent, ctx, OnNodeBuilt );

		var docIndex = parent.Children.IndexOf( record );
		if ( docIndex >= 0 && docIndex < parent.Children.Count - 1 && record.LivePanel is { IsValid: true } live )
			liveParent.SetChildIndex( live, docIndex );

		UpdateChromeLabel( parent );
		return true;
	}

	// Idempotent: empty non-root containers get exactly one .preview-chrome-label child.
	public void UpdateChromeLabel( ControlRecord record )
	{
		if ( record is null ) return;
		if ( record == _document.RootRecord ) return;
		if ( record.LivePanel is null || !record.LivePanel.IsValid ) return;
		if ( !ContractScanner.Table.Get( record.Type ).IsContainer ) return;

		Panel existing = null;
		foreach ( var child in record.LivePanel.Children )
		{
			if ( child.HasClass( "preview-chrome-label" ) ) { existing = child; break; }
		}

		var shouldShow = record.Children.Count == 0;

		if ( shouldShow && existing is null )
		{
			var label = record.LivePanel.AddChild<Sandbox.UI.Label>();
			label.Text = record.ClassName;
			label.AddClass( "preview-chrome-label" );
		}
		else if ( !shouldShow && existing is not null )
		{
			existing.Delete();
		}
	}

	// UpdateChromeLabel creates/removes; this updates the existing label's text after rename.
	public void RefreshChromeLabelText( ControlRecord record )
	{
		if ( record?.LivePanel is null || !record.LivePanel.IsValid ) return;
		foreach ( var child in record.LivePanel.Children )
		{
			if ( child.HasClass( "preview-chrome-label" ) && child is Sandbox.UI.Label lbl )
			{
				lbl.Text = record.ClassName;
				return;
			}
		}
	}

	public System.Collections.Generic.IEnumerable<Panel> TopLevelAuthoredPanels()
	{
		foreach ( var record in _document.RootRecord.Children )
		{
			var live = record?.LivePanel;
			if ( live is null || !live.IsValid ) continue;
			yield return live;
		}
	}

	private void OnNodeBuilt( IReadOnlyNode node )
	{
		if ( node is RecordNode rn )
			UpdateChromeLabel( rn.Backing );
	}

	public void SetPseudoClassForRecord( ControlRecord record, PseudoClass cls, bool on )
	{
		if ( record?.LivePanel is null ) return;

		record.LivePanel.Switch( cls, on );

		Log.Info( $"{LogPrefix} LiveTreeMirror.SetPseudoClassForRecord: record={record.ClassName} cls={cls} on={on}" );
	}
}