Editor/UI/ArchLayerPalettePicker.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Editor;
using Editor.MeshEditor;
using Sandbox;
using Margin = Sandbox.UI.Margin;

namespace Sunless.Architecture;

public static class ArchLayerPalettePicker {
	public sealed class MaterialIndex {
		public int Index { get; init; }
		public string Material { get; init; }
		public string Context { get; init; }
		public int FaceCount { get; init; }
		public int MaterialCount { get; init; } = 1;
		public List<ArchFaceDetail> Faces { get; init; } = new();
		public List<ArchSurface> Roles { get; init; } = new();
		public Vector2 MappingAnchor { get; init; }
	}

	public static bool CanOpen( ArchTool tool, ArchLayerNode node ) {
		return tool is not null && node?.Payload is not null && PaletteOf( node.Payload ) is not null;
	}

	public static bool HasOverride( ArchLayerNode node ) {
		var palette = PaletteOf( node?.Payload );

		return palette is not null && (palette.Materials.Count > 0 || palette.TexelScales.Count > 0 || palette.TextureOffsets?.Count > 0);
	}

	public static void Show( Widget parent, ArchTool tool, ArchLayerNode node ) {
		var palette = PaletteOf( node.Payload );

		if ( palette is null ) {
			return;
		}

		var indices = Scan( tool, node, palette );

		new ArchLayerPaletteDialog( parent, tool, node, palette, indices ).OpenAtCursor();
	}

	internal static List<MaterialIndex> Scan( ArchTool tool, ArchLayerNode layer, ArchPalette palette ) {
		var layerIds = DescendantsAndSelf( layer )
			.Select( node => node.Ref?.ItemId ?? 0 )
			.Where( id => id != 0 )
			.ToHashSet();
		var faces = layerIds
			.SelectMany( tool.BuiltFaces )
			.SelectMany( piece => piece.Faces )
			.ToList();

		if ( layer.Payload is ArchCutPart cut ) {
			var lift = new ArchSelection { Item = cut }.Lift( tool.Plan, tool.Kit );
			var everyFace = tool.EveryBuiltPiece().SelectMany( piece => piece.Faces ).ToList();
			var coveredFaces = everyFace
				.Where( face => cut.Segments.Any( segment => Covers( segment, face.Centre, lift ) ) )
				.ToList();

			if ( cut.ResolvedDamage == ArchDamageKind.Masonry ) {
				var coveredFittings = coveredFaces.Where( IsMasonryFitting ).ToHashSet();
				faces = ArchAudit.PhysicallyConnected( everyFace.Where( IsMasonryFitting ) )
					.Where( brick => brick.Any( coveredFittings.Contains ) )
					.SelectMany( brick => brick )
					.Distinct()
					.ToList();
			} else {
				faces = faces.Concat( coveredFaces ).Distinct().ToList();
			}
		}

		var contextual = faces
			.Select( face => (Face: face, Owner: tool.LayerTree.Find( face.LayerId )?.Payload, Role: ContextRole( tool, layer, face )) )
			.Where( entry => entry.Role.HasValue )
			.GroupBy( entry => entry.Role.Value )
			.Select( group => new MaterialIndex {
				Material = group.GroupBy( entry => entry.Face.Material ).OrderByDescending( material => material.Count() ).First().Key,
				Context = ContextName( group.Key, layer.Payload ),
				FaceCount = group.Count(),
				MaterialCount = group.Select( entry => entry.Face.Material ).Distinct().Count(),
				Faces = group.Select( entry => entry.Face ).ToList(),
				MappingAnchor = MappingAnchor( group.Select( entry => entry.Face ) ),
				Roles = new List<ArchSurface> { group.Key }
			} )
			.ToList();
		var unresolved = faces
			.Where( face => !ContextRole( tool, layer, face ).HasValue )
			.GroupBy( face => face.Material )
			.OrderByDescending( group => group.Count() )
			.Select( group => {
				var roles = group
					.SelectMany( face => face.Role.Split( '/', StringSplitOptions.RemoveEmptyEntries ) )
					.Select( ParseRole )
					.Where( role => role.HasValue )
					.Select( role => role.Value )
					.Distinct()
					.ToList();

				return new MaterialIndex {
					Material = group.Key,
					Context = roles.Count > 0 ? ContextName( roles[0], layer.Payload ) : "Unclassified Surface",
					FaceCount = group.Count(),
					MaterialCount = 1,
					Faces = group.ToList(),
					MappingAnchor = MappingAnchor( group ),
					Roles = roles
				};
			} )
			.Where( index => index.Roles.Count > 0 )
			.ToList();
		var indices = contextual
			.Concat( unresolved )
			.OrderByDescending( index => index.FaceCount )
			.Select( ( index, position ) => new MaterialIndex {
				Index = position,
				Material = index.Material,
				Context = index.Context,
				FaceCount = index.FaceCount,
				MaterialCount = index.MaterialCount,
				Faces = index.Faces,
				MappingAnchor = index.MappingAnchor,
				Roles = index.Roles
			} )
			.ToList();

		if ( layer.Payload is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry } && indices.Count > 0
			&& !indices.Any( index => index.Roles.Contains( ArchSurface.WallBase ) ) ) {
			var masonry = indices.FirstOrDefault( index => index.Roles.Contains( ArchSurface.WallExterior ) ) ?? indices[0];

			masonry.Roles.Add( ArchSurface.WallBase );
		}

		if ( indices.Count > 0 ) {
			return indices;
		}

		var roles = palette.Materials.Keys
			.Select( ParseRole )
			.Where( role => role.HasValue )
			.Select( role => role.Value )
			.Distinct()
			.ToList();

		return roles.Select( ( role, index ) => new MaterialIndex {
			Index = index,
			Material = palette.TryGet( role, out var path ) ? path : "",
			Context = ContextName( role, layer.Payload ),
			Roles = new List<ArchSurface> { role }
		} ).ToList();
	}

	static bool IsMasonryFitting( ArchFaceDetail face ) {
		return face.Piece
			.Split( '/', StringSplitOptions.RemoveEmptyEntries )
			.Contains( ArchPieces.Fittings );
	}

	static ArchSurface? ContextRole( ArchTool tool, ArchLayerNode layer, ArchFaceDetail face ) {
		var owner = tool.LayerTree.Find( face.LayerId )?.Payload;

		if ( layer.Payload is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry }
			|| owner is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry } ) {
			return ArchSurface.WallBase;
		}

		var pieces = face.Piece.Split( '/', StringSplitOptions.RemoveEmptyEntries );

		if ( pieces.Contains( ArchPieces.Skirting ) ) {
			return ArchSurface.Baseboard;
		}

		if ( pieces.Contains( ArchPieces.Soffit ) ) {
			return ArchSurface.Soffit;
		}

		if ( pieces.Contains( ArchPieces.Ceiling ) ) {
			return ArchSurface.Ceiling;
		}

		if ( pieces.Contains( ArchPieces.Bars ) ) {
			return ArchSurface.Grille;
		}

		if ( pieces.Contains( ArchPieces.Sign ) ) {
			return ArchSurface.Sign;
		}

		if ( pieces.Contains( ArchPieces.Plant ) ) {
			return ArchSurface.Plant;
		}

		if ( owner is ArchPipeBracketPart || pieces.Contains( ArchPieces.Brackets ) && owner is ArchPipePart ) {
			return ArchSurface.Bracket;
		}

		if ( owner is ArchPipePart pipe ) {
			return pipe.IsCable || pipe.IsTray ? ArchSurface.Cabling : ArchSurface.Pipework;
		}

		return null;
	}

	static string ContextName( ArchSurface role, object owner ) {
		if ( owner is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry } && role == ArchSurface.WallBase ) {
			return "Damage Bricks";
		}

		return role switch {
			ArchSurface.Baseboard => "Skirting",
			ArchSurface.WallExterior => "Exterior Walls",
			ArchSurface.WallInterior => "Interior Walls",
			ArchSurface.WallBase => "Wall Base",
			ArchSurface.WallCap => "Wall Tops",
			ArchSurface.Reveal => "Door and Window Reveals",
			ArchSurface.Trim => "Wall Trim",
			ArchSurface.Sill => "Window Sills",
			ArchSurface.Floor => "Floor Surface",
			ArchSurface.Ceiling => "Ceiling Surface",
			ArchSurface.Roof => "Roof Surface",
			ArchSurface.RoofEdge => "Roof Edge",
			ArchSurface.Soffit => "Roof Soffit",
			ArchSurface.Gutter => "Gutters",
			ArchSurface.Foundation => "Foundation",
			ArchSurface.StairTread => "Stair Treads",
			ArchSurface.StairRiser => "Stair Risers",
			ArchSurface.StairStringer => "Stair Stringers",
			ArchSurface.WindowFrame => "Window Frames",
			ArchSurface.WindowSash => "Window Sashes",
			ArchSurface.DoorLeaf => "Door Leaves",
			ArchSurface.Pipework => "Pipework",
			ArchSurface.Cabling => "Cabling",
			ArchSurface.Bracket => "Supports",
			_ => SplitName( role.ToString() )
		};
	}

	static string SplitName( string value ) {
		return string.Concat( value.Select( ( character, index ) => index > 0 && char.IsUpper( character ) ? $" {character}" : character.ToString() ) );
	}

	static Vector2 MappingAnchor( IEnumerable<ArchFaceDetail> faces ) {
		var weighted = Vector2.Zero;
		var weight = 0f;

		foreach ( var face in faces ) {
			var area = MathF.Max( 1f, face.Area );
			var projected = new Vector2(
				Vector3.Dot( face.TextureAxisU, face.Centre ),
				Vector3.Dot( face.TextureAxisV, face.Centre ) );

			weighted += projected * area;
			weight += area;
		}

		return weight > 0f ? weighted / weight : Vector2.Zero;
	}

	public static IEnumerable<ArchLayerNode> DescendantsAndSelf( ArchLayerNode node ) {
		if ( node is null ) {
			yield break;
		}

		yield return node;

		foreach ( var child in node.Children ) {
			foreach ( var descendant in DescendantsAndSelf( child ) ) {
				yield return descendant;
			}
		}
	}

	static bool Covers( ArchCutSegment segment, Vector3 point, float lift ) {
		return point.z >= segment.BaseHeight + lift - 0.5f
			&& point.z <= segment.TopHeight + lift + 0.5f
			&& ArchFootprint.Contains( segment.Outline(), new Vector2( point.x, point.y ) );
	}

	static ArchSurface? ParseRole( string value ) {
		return Enum.TryParse<ArchSurface>( value, out var role ) ? role : null;
	}

	public static ArchPalette PaletteOf( object item ) {
		return new ArchSelection { Item = item }.Palette;
	}
}

sealed class ArchMaterialSwatch : Widget {
	readonly Pixmap image;
	readonly Color? fill;

	public ArchMaterialSwatch( Widget parent, Pixmap image, Color? fill = null ) : base( parent ) {
		this.image = image;
		this.fill = fill;
		FixedSize = new Vector2( 44f, 44f );
	}

	protected override void OnPaint() {
		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground );
		Paint.DrawRect( LocalRect, 3f );

		if ( image is not null ) {
			Paint.Draw( LocalRect.Shrink( 2f ), image, 1f );
		}

		if ( fill is { } colour ) {
			Paint.ClearPen();
			Paint.SetBrush( colour.WithAlpha( image is null ? 1f : 0.62f ) );
			Paint.DrawRect( LocalRect.Shrink( 2f ), 2f );
		}

		Paint.SetPen( Theme.TextControl.WithAlpha( 0.35f ) );
		Paint.ClearBrush();
		Paint.DrawRect( LocalRect, 3f );
	}
}

sealed class ArchLayerMaterialPicker : Widget {
	readonly ArchMapPalette.Document document;
	readonly ArchMapPalette.Surface current;
	readonly Action<ArchMapPalette.Surface> picked;

	public ArchLayerMaterialPicker( ArchMapPalette.Document document, int index, ArchMapPalette.Surface current,
		Action<ArchMapPalette.Surface> picked ) : base( null ) {
		this.document = document;
		this.current = current;
		this.picked = picked;

		WindowFlags = WindowFlags.Tool | WindowFlags.FramelessWindowHint;
		DeleteOnClose = true;
		FixedSize = new Vector2( 430f, 420f );

		Build( index );
	}

	public void OpenAtCursor() {
		Position = global::Editor.Application.CursorPosition;
		Show();
		ConstrainToScreen();
	}

	void Build( int index ) {
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		var head = Layout.AddRow();
		head.Margin = new Margin( 10, 8, 10, 8 );
		head.Add( new Label( $"Choose Material · Material {index}" ), 1 );
		head.Add( new Button( "", "close" ) { Clicked = Destroy } );

		Layout.AddSeparator();

		var grid = new ArchTileGrid( this, ArchTileSize.Card, 8f );
		Layout.Add( grid, 1 );

		var section = new ArchTileSection {
			Title = string.IsNullOrWhiteSpace( document.Title ) ? document.Map : document.Title,
			Note = "Click a material preview to assign it to the checked index."
		};

		foreach ( var surface in document.Surfaces ) {
			var captured = surface;
			var missing = !ArchMapPalette.IsImported( captured );

			section.Tiles.Add( parent => new ArchTile( parent,
				ArchThumb.For( captured.Category, captured.Name, captured.Id ), ShortName( captured.Name ),
				missing ? "NOT IMPORTED" : captured.Category, missing, ArchTileSize.Card ) {
				ToolTip = captured.MaterialPath,
				Current = () => ReferenceEquals( current, captured ),
				Clicked = missing ? null : () => Pick( captured )
			} );
		}

		grid.Set( new[] { section } );
	}

	void Pick( ArchMapPalette.Surface surface ) {
		picked?.Invoke( surface );
		Destroy();
	}

	static string ShortName( string name ) {
		if ( name.StartsWith( "map_seamless_", StringComparison.OrdinalIgnoreCase ) ) {
			return name[13..];
		}

		if ( name.StartsWith( "map_trim_", StringComparison.OrdinalIgnoreCase ) ) {
			return name[9..];
		}

		return name;
	}
}

sealed class ArchPaletteOverridePrompt : Widget {
	readonly Action keep;
	readonly Action replace;

	public ArchPaletteOverridePrompt( string layerName, int overrideCount, int layerCount, Action keep, Action replace ) : base( null ) {
		this.keep = keep;
		this.replace = replace;

		WindowFlags = WindowFlags.Tool | WindowFlags.FramelessWindowHint;
		DeleteOnClose = true;
		FixedSize = new Vector2( 430f, 172f );

		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		var head = Layout.AddRow();
		head.Margin = new Margin( 12, 9, 10, 9 );
		head.Add( new Label( "Palette Overrides" ), 1 );
		head.Add( new Button( "", "close" ) { Clicked = Destroy } );

		Layout.AddSeparator();

		var body = Layout.AddColumn( 1 );
		body.Margin = new Margin( 14, 12, 14, 10 );
		body.Spacing = 5;

		var message = new Label( $"{layerName} contains {overrideCount} palette {(overrideCount == 1 ? "override" : "overrides")} across {layerCount} {(layerCount == 1 ? "layer" : "layers")}." ) {
			WordWrap = true
		};
		body.Add( message );

		var detail = new Label( "Keep the detailed layer choices, or replace them so this palette controls those materials." ) {
			WordWrap = true
		};
		detail.SetStyles( "color: rgba(255,255,255,0.55); font-size: 11px;" );
		body.Add( detail );

		Layout.AddSeparator();

		var footer = Layout.AddRow();
		footer.Margin = new Margin( 12, 8, 12, 10 );
		footer.Spacing = 6;
		footer.AddStretchCell();
		footer.Add( new Button( "Keep Existing Overrides", "layers" ) { Clicked = Keep } );
		footer.Add( new Button.Primary( "Replace Overrides", "palette" ) { Clicked = Replace } );
	}

	public void OpenAtCursor() {
		Position = global::Editor.Application.CursorPosition - new Vector2( 80f, 0f );
		Show();
		ConstrainToScreen();
	}

	void Keep() {
		Destroy();
		keep?.Invoke();
	}

	void Replace() {
		Destroy();
		replace?.Invoke();
	}
}

public sealed class ArchLayerPaletteDialog : Widget {
	sealed class UvMapping {
		public float Scale { get; set; }
		public float OffsetX { get; set; }
		public float OffsetY { get; set; }
	}

	sealed record PaletteAssignment( ArchSurface Role, string Path, float TexelScale, Vector2 Offset );
	sealed record PaletteConflict( ArchLayerNode Layer, ArchPalette Palette, ArchSurface Role );

	readonly ArchTool tool;
	readonly ArchLayerNode layer;
	readonly ArchPalette target;
	readonly List<ArchLayerPalettePicker.MaterialIndex> indices;
	readonly List<ArchMapPalette.Document> documents = new();
	readonly Dictionary<int, ArchMapPalette.Surface> assignments = new();
	readonly Dictionary<int, Material> workingAssignments = new();
	readonly Dictionary<int, UvMapping> uvMappings = new();
	readonly Dictionary<int, float> previewScales = new();
	readonly HashSet<int> selectedIndices = new();

	Layout rows;
	Label detail;
	Button applyOne;

	ArchMapPalette.Document document;
	int lastClickedIndex = -1;
	bool blockout;

	public ArchLayerPaletteDialog( Widget parent, ArchTool tool, ArchLayerNode layer, ArchPalette target,
		List<ArchLayerPalettePicker.MaterialIndex> indices ) : base( null ) {
		this.tool = tool;
		this.layer = layer;
		this.target = target;
		this.indices = indices;

		foreach ( var file in ArchMapPalette.Available() ) {
			if ( ArchMapPalette.ReadDocument( file ) is { } available ) {
				documents.Add( available );
			}
		}

		document = documents.FirstOrDefault( available => available.Surfaces.Any( surface =>
			target.Materials.Values.Any( path => path.Equals( surface.MaterialPath, StringComparison.OrdinalIgnoreCase ) ) ) )
			?? documents.FirstOrDefault();
		blockout = target.Materials.Values.Any( path => path.Contains( ArchBlockout.Folder, StringComparison.OrdinalIgnoreCase ) )
			|| document is null;

		if ( indices.FirstOrDefault() is { } first ) {
			selectedIndices.Add( first.Index );
			lastClickedIndex = first.Index;
		}

		WindowFlags = WindowFlags.Tool | WindowFlags.FramelessWindowHint;
		DeleteOnClose = true;

		FixedWidth = 780f;
		FixedHeight = Math.Clamp( 250f + indices.Count * 72f, 420f, 680f );

		Build();
		MapAssignments();
		RebuildRows();
	}

	public void OpenAtCursor() {
		Position = global::Editor.Application.CursorPosition - new Vector2( FixedWidth - 110f, 0f );
		Show();
		ConstrainToScreen();
	}

	void Build() {
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		var head = Layout.AddColumn();
		head.Margin = new Margin( 12, 10, 12, 10 );
		head.Spacing = 5;

		var heading = new Label( $"Apply Palette · {layer.DisplayName}" );
		heading.SetStyles( "font-size: 15px; font-weight: 700;" );
		head.Add( heading );

		detail = new Label( $"{indices.Count} contextual {(indices.Count == 1 ? "surface" : "surfaces")} found" );
		detail.SetStyles( "color: rgba(255,255,255,0.55); font-size: 11px;" );
		head.Add( detail );

		var paletteRow = head.AddRow();
		paletteRow.Spacing = 8;
		paletteRow.Add( new Label( "Built-in palette" ) { MinimumWidth = 120 } );

		var palettes = new ComboBox( this );
		palettes.AddItem( "Blockout Grid", "grid_on", () => ChooseBlockout(), selected: blockout );

		foreach ( var available in documents ) {
			var captured = available;
			var title = string.IsNullOrWhiteSpace( captured.Title ) ? captured.Map : captured.Title;

			palettes.AddItem( title, "palette", () => Choose( captured ), selected: ReferenceEquals( document, captured ) );
		}

		paletteRow.Add( palettes, 1 );

		Layout.AddSeparator();

		var body = Layout.AddRow( 1 );

		var scroll = new ScrollArea( this );
		scroll.HorizontalScrollbarMode = ScrollbarMode.Off;

		var canvas = new Widget( scroll ) { Layout = Layout.Column() };
		canvas.Layout.Margin = 10;
		canvas.Layout.Spacing = 2;
		scroll.Canvas = canvas;
		rows = canvas.Layout;

		body.Add( scroll, 1 );
		body.AddSeparator();

		var working = new Widget( this ) { Layout = Layout.Column() };
		working.FixedWidth = 84f;
		working.Layout.Margin = new Margin( 8, 10, 8, 8 );
		working.Layout.Spacing = 5;

		var workingLabel = new Label.Small( "MAPPING" );
		workingLabel.ToolTip = "The active temporary palette shared with the Mapping tool";
		working.Layout.Add( workingLabel );

		var workingPalette = new MaterialPaletteWidget {
			FixedWidth = 64f,
			FixedHeight = 204f,
			GetActiveMaterial = ActiveMappingMaterial
		};

		workingPalette.MaterialClicked += AssignWorkingMaterial;
		working.Layout.Add( workingPalette );
		working.Layout.AddStretchCell();

		body.Add( working );
		Layout.AddSeparator();

		var footer = Layout.AddRow();
		footer.Margin = new Margin( 12, 8, 12, 10 );
		footer.Spacing = 6;

		footer.Add( new Button( "Close", "close" ) { Clicked = Destroy } );
		footer.AddStretchCell();

		applyOne = new Button.Primary( "", "check" ) { Clicked = ApplySelected };
		footer.Add( applyOne );

		var every = new Button( "Apply to Every Index", "done_all" ) { Clicked = ApplyEvery };
		footer.Add( every );

		RefreshApplyLabel();
	}

	void ChooseBlockout() {
		blockout = true;
		document = null;
		MapAssignments();
		RebuildRows();
	}

	void Choose( ArchMapPalette.Document chosen ) {
		blockout = false;
		document = chosen;
		MapAssignments();
		RebuildRows();
	}

	void MapAssignments() {
		assignments.Clear();
		workingAssignments.Clear();
		uvMappings.Clear();
		previewScales.Clear();

		if ( !blockout && document is not null ) {
			MapMaterialAssignments();
		}

		MapUvAssignments();
	}

	void MapMaterialAssignments() {

		var resolved = ArchMapPalette.Resolve( document );

		foreach ( var index in indices ) {
			var assigned = RolesFor( index )
				.Where( resolved.ContainsKey )
				.Select( role => resolved[role] )
				.FirstOrDefault( ArchMapPalette.IsImported )
				?? document.Surfaces.FirstOrDefault( ArchMapPalette.IsImported );

			if ( assigned is not null ) {
				assignments[index.Index] = assigned;
			}

			var overriddenPath = RolesFor( index )
				.Select( role => target.TryGet( role, out var path ) ? path : null )
				.FirstOrDefault( path => !string.IsNullOrWhiteSpace( path ) );

			if ( string.IsNullOrWhiteSpace( overriddenPath ) ) {
				continue;
			}

			var overriddenSurface = document.Surfaces.FirstOrDefault( surface =>
				surface.MaterialPath.Equals( overriddenPath, StringComparison.OrdinalIgnoreCase ) );

			if ( overriddenSurface is not null ) {
				assignments[index.Index] = overriddenSurface;
				continue;
			}

			var overriddenMaterial = Material.Load( overriddenPath );

			if ( overriddenMaterial is not null && overriddenMaterial.IsValid() ) {
				workingAssignments[index.Index] = overriddenMaterial;
			}
		}
	}

	void MapUvAssignments() {
		foreach ( var index in indices ) {
			var scale = RolesFor( index ).Select( target.ScaleFor ).FirstOrDefault( value => value > 0f );

			if ( scale <= 0f && assignments.TryGetValue( index.Index, out var surface ) ) {
				scale = surface.TexelScale;
			}

			var offset = RolesFor( index ).Select( target.OffsetFor ).FirstOrDefault( value => !value.IsNearZeroLength );

			uvMappings[index.Index] = new UvMapping {
				Scale = scale > 0f ? scale : ArchMesh.TexelScale,
				OffsetX = offset.x,
				OffsetY = offset.y
			};
			previewScales[index.Index] = uvMappings[index.Index].Scale;
		}
	}

	void RebuildRows() {
		rows.Clear( true );

		if ( indices.Count == 0 ) {
			rows.Add( new Label( "This layer has no generated or assigned materials yet." ) );
			applyOne.Enabled = false;
			return;
		}

		applyOne.Enabled = true;

		foreach ( var index in indices ) {
			var captured = index;
			var row = new Widget( this ) { Layout = Layout.Column() };
			row.Layout.Spacing = 4;

			var materialRow = row.Layout.AddRow();
			materialRow.Spacing = 8;

			var chooseIndex = new Button( captured.Context, selectedIndices.Contains( captured.Index ) ? "check_box" : "check_box_outline_blank" ) {
				MinimumWidth = 122,
				ToolTip = $"{captured.FaceCount} generated {(captured.FaceCount == 1 ? "face" : "faces")} · {captured.MaterialCount} source {(captured.MaterialCount == 1 ? "material" : "materials")}",
				Clicked = () => ToggleIndex( captured.Index )
			};

			materialRow.Add( chooseIndex );
			materialRow.Add( new Button( "", "texture" ) {
				FixedWidth = 32f,
				ToolTip = $"Edit {captured.Context} faces and linked variations",
				Clicked = () => OpenContextFaceTool( captured )
			} );
			materialRow.Add( CurrentSwatch( row, captured ) );
			var currentMaterial = captured.MaterialCount == 1
				? ShortMaterial( captured.Material )
				: $"{captured.MaterialCount} source materials";
			materialRow.Add( new Label( currentMaterial ) );
			materialRow.Add( new Label( "→" ) { FixedWidth = 12f } );
			materialRow.Add( AssignedSwatch( row, captured ) );

			var materialName = workingAssignments.TryGetValue( captured.Index, out var working )
				? ShortMaterial( working.ResourcePath )
				: blockout
					? "Blockout Grid"
					: assignments.TryGetValue( captured.Index, out var assigned )
						? MaterialName( assigned )
						: "Choose material";

			var materials = new Button( materialName, "arrow_drop_down" ) {
				MinimumWidth = 172,
				Enabled = !blockout && document is not null,
				Clicked = () => OpenMaterialPicker( captured.Index )
			};

			materialRow.Add( materials );

			var uvRow = row.Layout.AddRow();
			uvRow.Spacing = 5;

			var serialized = uvMappings[captured.Index].GetSerialized();
			serialized.OnPropertyChanged += property => ChangeUvMapping( captured, property );
			serialized.OnPropertyFinishEdit += _ => FinishUvMapping( captured );
			uvRow.Add( ScaleControl( serialized, captured ) );
			uvRow.AddStretchCell();

			rows.Add( row );
		}

		rows.AddStretchCell();
		RefreshApplyLabel();
	}

	Widget ScaleControl( SerializedObject serialized, ArchLayerPalettePicker.MaterialIndex index ) {
		var holder = new Widget( this ) { Layout = Layout.Row() };
		holder.Layout.Spacing = 2;
		holder.Layout.Add( new Button( "", "remove" ) { FixedWidth = 22f, Clicked = () => BumpScale( index, -1 ) } );
		holder.Layout.Add( UvControl( serialized, nameof( UvMapping.Scale ), "S", Theme.Blue, true ) );
		holder.Layout.Add( new Button( "", "add" ) { FixedWidth = 22f, Clicked = () => BumpScale( index, 1 ) } );

		return holder;
	}

	FloatControlWidget UvControl( SerializedObject serialized, string property, string label, Color colour, bool positive ) {
		var control = new FloatControlWidget( serialized.GetProperty( property ) ) {
			Label = label,
			HighlightColor = colour,
			FixedWidth = positive ? 72f : 82f,
			FixedHeight = Theme.RowHeight,
			ToolTip = "Drag horizontally or type a value"
		};

		control.MakeRanged( positive ? new Vector2( 0.25f, 8f ) : new Vector2( -4096f, 4096f ), positive ? 0.01f : 0.0025f, positive, false );

		return control;
	}

	void BumpScale( ArchLayerPalettePicker.MaterialIndex index, int direction ) {
		var mapping = uvMappings[index.Index];
		var steps = new[] { 0.25f, 0.5f, 1f, 2f, 4f, 8f };
		var scale = direction < 0
			? steps.LastOrDefault( value => value < mapping.Scale - 0.001f, steps[0] )
			: steps.FirstOrDefault( value => value > mapping.Scale + 0.001f, steps[^1] );

		ChangeScaleAroundAnchor( index, scale );

		PreviewUvMapping( index );
		CommitUvMapping( index );
	}

	void ChangeUvMapping( ArchLayerPalettePicker.MaterialIndex index, SerializedProperty property ) {
		if ( property.Name == nameof( UvMapping.Scale ) ) {
			ChangeScaleAroundAnchor( index, uvMappings[index.Index].Scale );
		}

		PreviewUvMapping( index );
	}

	void ChangeScaleAroundAnchor( ArchLayerPalettePicker.MaterialIndex index, float scale ) {
		var mapping = uvMappings[index.Index];
		var previous = previewScales.GetValueOrDefault( index.Index, mapping.Scale );
		var settled = Math.Clamp( scale, 0.01f, 8f );

		if ( previous > 0f && !previous.AlmostEqual( settled ) ) {
			var compensation = index.MappingAnchor * (1f / previous - 1f / settled);

			mapping.OffsetX += compensation.x;
			mapping.OffsetY += compensation.y;
		}

		mapping.Scale = settled;
		previewScales[index.Index] = settled;
	}

	void PreviewUvMapping( ArchLayerPalettePicker.MaterialIndex index ) {
		var mapped = AssignmentsFor( new[] { index } ).ToList();

		if ( mapped.Count == 0 ) {
			return;
		}

		Write( mapped );

		var layerId = layer.Ref?.ItemId ?? 0;
		tool.Affect( ArchLayerGroups.Holding( tool.Plan, layerId )?.Id ?? layerId );
		tool.Preview();
	}

	void FinishUvMapping( ArchLayerPalettePicker.MaterialIndex index ) {
		var mapping = uvMappings[index.Index];

		mapping.OffsetX = mapping.OffsetX.SnapToGrid( 0.25f );
		mapping.OffsetY = mapping.OffsetY.SnapToGrid( 0.25f );

		PreviewUvMapping( index );
		CommitUvMapping( index );
	}

	void CommitUvMapping( ArchLayerPalettePicker.MaterialIndex index ) {
		var layerId = layer.Ref?.ItemId ?? 0;

		tool.Affect( ArchLayerGroups.Holding( tool.Plan, layerId )?.Id ?? layerId );
		tool.Commit( $"Align {index.Context} on {layer.DisplayName}" );
	}

	Widget CurrentSwatch( Widget parent, ArchLayerPalettePicker.MaterialIndex index ) {
		var asset = AssetSystem.FindByPath( index.Material );

		if ( asset is not null && !asset.IsDeleted ) {
			return new ArchMaterialSwatch( parent, asset.GetAssetThumb( true ) );
		}

		var surface = documents
			.SelectMany( available => available.Surfaces )
			.FirstOrDefault( candidate => candidate.MaterialPath.Equals( index.Material, StringComparison.OrdinalIgnoreCase ) );

		return surface is null
			? new ArchMaterialSwatch( parent, null )
			: new ArchMaterialSwatch( parent, ArchThumb.For( surface.Category, surface.Name, surface.Id ) );
	}

	Widget AssignedSwatch( Widget parent, ArchLayerPalettePicker.MaterialIndex index ) {
		if ( workingAssignments.TryGetValue( index.Index, out var material ) ) {
			return new ArchMaterialSwatch( parent, AssetSystem.FindByPath( material.ResourcePath )?.GetAssetThumb( true ) );
		}

		if ( blockout ) {
			return new ArchMaterialSwatch( parent, ArchBlockout.Grid(), ArchBlockout.ColourOf( index.Roles.First() ) );
		}

		return assignments.TryGetValue( index.Index, out var surface )
			? new ArchMaterialSwatch( parent, ArchThumb.For( surface.Category, surface.Name, surface.Id ) )
			: new ArchMaterialSwatch( parent, null );
	}

	void Assign( int index, ArchMapPalette.Surface surface ) {
		workingAssignments.Remove( index );
		assignments[index] = surface;

		if ( surface.TexelScale > 0f ) {
			uvMappings[index].Scale = surface.TexelScale;
		}

		RebuildRows();
	}

	void OpenMaterialPicker( int index ) {
		if ( document is null ) {
			return;
		}

		assignments.TryGetValue( index, out var current );

		new ArchLayerMaterialPicker( document, index, current, surface => Assign( index, surface ) ).OpenAtCursor();
	}

	void AssignWorkingMaterial( Material material ) {
		if ( material is null || !material.IsValid() || !selectedIndices.Contains( lastClickedIndex ) ) {
			return;
		}

		workingAssignments[lastClickedIndex] = material;

		RebuildRows();
	}

	static Material ActiveMappingMaterial() {
		var path = ProjectCookie.Get( "MeshTool.ActiveMaterial", "" );

		return string.IsNullOrWhiteSpace( path ) ? null : Material.Load( path );
	}

	void ToggleIndex( int index ) {
		if ( selectedIndices.Add( index ) ) {
			lastClickedIndex = index;
		} else {
			selectedIndices.Remove( index );

			if ( lastClickedIndex == index ) {
				lastClickedIndex = selectedIndices.Count > 0 ? selectedIndices.First() : -1;
			}
		}

		RebuildRows();
	}

	void RefreshApplyLabel() {
		applyOne.Enabled = selectedIndices.Count > 0;
		var selected = indices.FirstOrDefault( index => selectedIndices.Contains( index.Index ) );
		applyOne.Text = selectedIndices.Count switch {
			0 => "Select Surfaces",
			1 => $"Apply to {selected?.Context ?? "Surface"}",
			_ => $"Apply to {selectedIndices.Count} Surfaces"
		};
	}

	void OpenContextFaceTool( ArchLayerPalettePicker.MaterialIndex index ) {
		if ( SceneEditorSession.Active is null ) {
			Log.Warning( "Architecture: the Context Face Tool needs an active scene editor session." );
			return;
		}

		tool.Disown();
		tool.Regenerate();

		var currentIndex = ArchLayerPalettePicker.Scan( tool, layer, target )
			.FirstOrDefault( candidate => candidate.Context == index.Context ) ?? index;
		var faceVariations = ArchFaceUvMappings.Variations( tool, currentIndex );
		var faces = faceVariations.SelectMany( variation => variation ).SelectMany( group => group ).Distinct().ToArray();

		if ( faces.Length == 0 ) {
			Log.Warning( $"Architecture: no generated faces remain for the selected {layer.DisplayName} surfaces." );
			return;
		}

		SceneEditorSession.Active.Selection.Clear();

		foreach ( var face in faces ) {
			SceneEditorSession.Active.Selection.Add( face );
		}

		if ( target.FaceMappings.TryGetValue( index.Context, out var stored ) ) {
			ArchFaceUvMappings.Apply( stored, faceVariations );
		}

		var selectedMaterial = FastTextureMaterial( index );
		ArchTextureGroupEditor.Show( this, faceVariations, selectedMaterial, mapping => {
			Write( AssignmentsFor( new[] { index } ) );
			target.FaceMappings[index.Context] = mapping;
			var layerId = layer.Ref?.ItemId ?? 0;
			tool.Affect( ArchLayerGroups.Holding( tool.Plan, layerId )?.Id ?? layerId );
			tool.Commit( $"Map {index.Context} on {layer.DisplayName}" );
		} );
	}

	Material FastTextureMaterial( ArchLayerPalettePicker.MaterialIndex selected ) {
		if ( workingAssignments.TryGetValue( selected.Index, out var working ) && working.IsValid() ) {
			return working;
		}

		if ( blockout ) {
			return Material.Load( ArchBlockout.PathFor( selected.Roles.First() ) );
		}

		return assignments.TryGetValue( selected.Index, out var assigned )
			? Material.Load( assigned.MaterialPath )
			: null;
	}

	MeshFace MeshFaceFor( ArchFaceDetail face ) {
		if ( !tool.BuiltMeshes.TryGetValue( face.LayerId, out var components ) ) {
			return default;
		}

		var component = components.FirstOrDefault( candidate => ArchAudit.Path( candidate.GameObject ) == face.Piece );

		if ( !component.IsValid() || component.Mesh is null ) {
			return default;
		}

		var handle = component.Mesh.FaceHandleFromIndex( face.HandleIndex );

		return handle.IsValid ? new MeshFace( component, handle ) : default;
	}

	void ApplySelected() {
		Apply( indices.Where( index => selectedIndices.Contains( index.Index ) ) );
	}

	void ApplyEvery() {
		Apply( indices );
	}

	void Apply( IEnumerable<ArchLayerPalettePicker.MaterialIndex> scope ) {
		var assignmentsToApply = AssignmentsFor( scope ).GroupBy( assignment => assignment.Role ).Select( group => group.Last() ).ToList();

		if ( assignmentsToApply.Count == 0 ) {
			Log.Warning( $"Architecture: no imported palette materials could be applied to {layer.DisplayName}." );
			return;
		}

		var conflicts = ConflictsFor( assignmentsToApply.Select( assignment => assignment.Role ) );

		if ( conflicts.Count == 0 ) {
			Apply( assignmentsToApply, conflicts, true );
			return;
		}

		new ArchPaletteOverridePrompt( layer.DisplayName, conflicts.Count, conflicts.Select( conflict => conflict.Layer ).Distinct().Count(),
			() => Apply( assignmentsToApply, conflicts, false ),
			() => Apply( assignmentsToApply, conflicts, true ) ).OpenAtCursor();
	}

	IEnumerable<PaletteAssignment> AssignmentsFor( IEnumerable<ArchLayerPalettePicker.MaterialIndex> scope ) {
		foreach ( var index in scope ) {
			if ( workingAssignments.TryGetValue( index.Index, out var working ) && working.IsValid() ) {
				foreach ( var role in RolesFor( index ) ) {
					yield return AssignmentFor( index, role, working.ResourcePath );
				}

				continue;
			}

			if ( blockout ) {
				foreach ( var role in RolesFor( index ) ) {
					yield return AssignmentFor( index, role, ArchBlockout.PathFor( role ) );
				}

				continue;
			}

			if ( !assignments.TryGetValue( index.Index, out var surface ) || !ArchMapPalette.IsImported( surface ) ) {
				continue;
			}

			foreach ( var role in RolesFor( index ) ) {
				yield return AssignmentFor( index, role, surface.MaterialPath );
			}
		}
	}

	PaletteAssignment AssignmentFor( ArchLayerPalettePicker.MaterialIndex index, ArchSurface role, string path ) {
		var mapping = uvMappings[index.Index];

		return new PaletteAssignment( role, path, mapping.Scale,
			new Vector2( mapping.OffsetX, mapping.OffsetY ) );
	}

	IEnumerable<ArchSurface> RolesFor( ArchLayerPalettePicker.MaterialIndex index ) {
		foreach ( var role in index.Roles ) {
			yield return role;
		}

		if ( layer.Payload is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry }
			&& !index.Roles.Contains( ArchSurface.WallBase ) ) {
			yield return ArchSurface.WallBase;
		}
	}

	List<PaletteConflict> ConflictsFor( IEnumerable<ArchSurface> roles ) {
		var affectedRoles = roles.ToHashSet();
		var conflicts = new List<PaletteConflict>();

		foreach ( var candidate in ArchLayerPalettePicker.DescendantsAndSelf( layer ).Skip( 1 ) ) {
			var palette = ArchLayerPalettePicker.PaletteOf( candidate.Payload );

			if ( palette is null ) {
				continue;
			}

			foreach ( var role in affectedRoles ) {
				var key = role.ToString();

				if ( palette.Materials.ContainsKey( key ) || palette.TexelScales.ContainsKey( key ) || palette.TextureOffsets?.ContainsKey( key ) == true ) {
					conflicts.Add( new PaletteConflict( candidate, palette, role ) );
				}
			}
		}

		return conflicts;
	}

	void Apply( IReadOnlyList<PaletteAssignment> assignmentsToApply, IReadOnlyList<PaletteConflict> conflicts, bool replace ) {
		if ( replace ) {
			foreach ( var conflict in conflicts ) {
				conflict.Palette.Set( conflict.Role, null );
			}
		}

		Write( assignmentsToApply );

		var layerId = layer.Ref?.ItemId ?? 0;
		tool.Affect( ArchLayerGroups.Holding( tool.Plan, layerId )?.Id ?? layerId );
		tool.Commit( $"Apply Palette to {layer.DisplayName}" );

		MapAssignments();
		RebuildRows();
	}

	void Write( IEnumerable<PaletteAssignment> mapped ) {
		foreach ( var assignment in mapped ) {
			target.Set( assignment.Role, assignment.Path, assignment.TexelScale );
			target.SetOffset( assignment.Role, assignment.Offset );
		}

		ArchStyle.InvalidateCache();
	}

	static string MaterialName( ArchMapPalette.Surface surface ) {
		var name = surface.Name;

		if ( name.StartsWith( "map_seamless_", StringComparison.OrdinalIgnoreCase ) ) {
			name = name[13..];
		}

		return $"{name} · {surface.Category}";
	}

	static string ShortMaterial( string path ) {
		if ( string.IsNullOrWhiteSpace( path ) || path == "none" ) {
			return "Unassigned";
		}

		return Path.GetFileNameWithoutExtension( path );
	}
}