Editor/UI/ArchWorkflow.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// A widget that prints its own heading when it stands loose in a panel, and drops it when its host already
// draws one - a workflow step being the host that does.
public interface IArchHeaded {
	void DropHeading();
}

// A tool's choices as a sequence. The step being asked is open, and so is the one that led to it - a step is not
// closed until the NEXT one is answered, so the choice you just made stays readable beside the choice you are
// making. Every step is a row either way, so nothing is ever more than one click away.
//
// Steps are keyed by NAME, never by ordinal: a step comes and goes with the answer above it, so numbering the
// memory would swap the reach step and the dressing step the moment Subtract was chosen.
public sealed class ArchWorkflow : IDisposable {
	// Where the sequence stands, and the step trailing it. Held off the cookies on purpose: the sequence starts
	// again every time the tool is picked, so none of it should outlive the session.
	static readonly Dictionary<string, string> standing = new();
	static readonly Dictionary<string, string> trailing = new();
	static readonly Dictionary<string, string> advancing = new();

	readonly ToolSidebarWidget panel;
	readonly string scope;
	readonly Action refresh;
	readonly List<Waiting> waiting = new();

	ArchWorkflow( ToolSidebarWidget panel, string scope, Action refresh ) {
		this.panel = panel;
		this.scope = scope;
		this.refresh = refresh;
	}

	public static ArchWorkflow In( ToolSidebarWidget panel, string scope, Action refresh ) {
		return new ArchWorkflow( panel, scope, refresh );
	}

	// Picking the tool starts the sequence over, so the first step is open and asking.
	public static void Restart( string scope ) {
		standing.Remove( scope );
		trailing.Remove( scope );
		advancing.Remove( scope );
	}

	public void Step( string title, string answer, string icon, Action<ArchWorkflowStep> build ) {
		waiting.Add( new Waiting { Title = title, Answer = answer, Icon = icon, Build = build } );
	}

	// The rows are laid HERE rather than as they are declared, because which one is open depends on the whole
	// sequence: the step after the one just answered is only knowable once every step has named itself, and a
	// cursor left on a step this build never laid down has to fall back to the first rather than open nothing.
	public void Dispose() {
		if ( waiting.Count == 0 ) {
			return;
		}

		var at = Cursor();
		var from = trailing.TryGetValue( scope, out var led ) ? led : null;

		for ( var index = 0; index < waiting.Count; index++ ) {
			var step = waiting[index];
			var open = step.Title == at || step.Title == from;
			var row = new ArchWorkflowRow( index + 1, step.Title, step.Answer, step.Icon, open, () => Aim( step.Title ) );

			if ( open ) {
				step.Build( new ArchWorkflowStep( row.Content, () => Advance( step.Title ) ) );
			}

			panel.Layout.Add( row );
		}
	}

	string Cursor() {
		var at = standing.TryGetValue( scope, out var held ) ? held : null;

		if ( advancing.TryGetValue( scope, out var after ) ) {
			advancing.Remove( scope );

			var answered = waiting.FindIndex( step => step.Title == after );

			if ( answered >= 0 && answered + 1 < waiting.Count ) {
				at = waiting[answered + 1].Title;
			}
		}

		if ( at is null || waiting.All( step => step.Title != at ) ) {
			at = waiting[0].Title;
			trailing.Remove( scope );
		}

		standing[scope] = at;

		return at;
	}

	void Advance( string title ) {
		advancing[scope] = title;
		trailing[scope] = title;

		refresh?.Invoke();
	}

	// A row clicked deliberately is the author jumping, so nothing trails it - and the row already open is where
	// they already are, which is not a click that should leave the panel with nothing on it.
	void Aim( string title ) {
		standing[scope] = title;
		trailing.Remove( scope );
		advancing.Remove( scope );

		refresh?.Invoke();
	}

	sealed class Waiting {
		public string Title;
		public string Answer;
		public string Icon;
		public Action<ArchWorkflowStep> Build;
	}
}

// Handed to a step so its controls can say they have ANSWERED, which is what moves the author on. A form that
// is typed into rather than picked from never answers - a number is not a decision the panel can see the end of.
public sealed class ArchWorkflowStep {
	readonly Action chose;

	internal ArchWorkflowStep( Layout layout, Action chose ) {
		Layout = layout;
		this.chose = chose;
	}

	public Layout Layout { get; }

	// A shelf carries its own caption for the panels it stands loose in. Inside a step the numbered row above
	// already prints that word, so anything added here drops its heading rather than saying it twice.
	public void Add( Widget widget ) {
		if ( widget is IArchHeaded headed ) {
			headed.DropHeading();
		}

		Layout.Add( widget );
	}

	public void Chose() => chose();
}

sealed class ArchWorkflowRow : Widget {
	const float HeaderHeight = 26f;
	const int AnswerLength = 24;

	readonly int number;
	readonly string title;
	readonly string answer;
	readonly string icon;
	readonly bool open;
	readonly Action toggle;
	readonly Widget content;

	bool hovered;

	public ArchWorkflowRow( int number, string title, string answer, string icon, bool open, Action toggle ) {
		this.number = number;
		this.title = title;
		this.answer = Short( answer );
		this.icon = icon;
		this.open = open;
		this.toggle = toggle;

		Layout = Layout.Column();
		Layout.Spacing = 2;

		// Tight sides on purpose: a step routinely holds a three-wide icon grid, and every inch of inset here is an
		// inch that grid loses to a fourth column it cannot have.
		Layout.Margin = new Sandbox.UI.Margin( 4, HeaderHeight, 4, open ? 6 : 1 );

		content = new Widget( this ) { Layout = Layout.Column() };
		content.Layout.Spacing = 3;
		content.Visible = open;

		Layout.Add( content );

		HorizontalSizeMode = SizeMode.Expand | SizeMode.CanGrow;
		VerticalSizeMode = SizeMode.CanShrink;
		MouseTracking = true;
	}

	public Layout Content => content.Layout;

	// A zero hint takes the height from the content's own minimum, which is what keeps a collapsed row one line.
	protected override Vector2 SizeHint() => 0;

	static string Short( string text ) {
		if ( string.IsNullOrWhiteSpace( text ) ) {
			return "";
		}

		return text.Length <= AnswerLength ? text : $"{text[..(AnswerLength - 1)]}…";
	}

	protected override void OnMouseMove( MouseEvent e ) {
		var was = hovered;

		hovered = !open && e.LocalPosition.y < HeaderHeight;

		if ( was != hovered ) {
			Update();
		}
	}

	protected override void OnMouseLeave() {
		if ( !hovered ) {
			return;
		}

		hovered = false;
		Update();
	}

	protected override void OnMouseClick( MouseEvent e ) {
		if ( open || e.LocalPosition.y > HeaderHeight ) {
			return;
		}

		toggle();
		e.Accepted = true;
	}

	protected override void OnPaint() {
		Paint.Antialiasing = true;
		Paint.TextAntialiasing = true;
		Cursor = open ? CursorShape.Arrow : CursorShape.Finger;

		var body = Paint.LocalRect;
		var header = new Rect( 0f, 2f, body.Width, HeaderHeight - 4f );

		if ( open ) {
			var frame = body.Shrink( 0f, HeaderHeight - 2f, 1f, 1f );

			Paint.SetBrushAndPen( Theme.Text.WithAlpha( 0.012f ), Theme.Text.WithAlpha( 0.09f ) );
			Paint.DrawRect( frame, 4 );
		}

		var badge = new Rect( header.Left + 2f, header.Top + 3f, 15f, 15f );

		Paint.SetBrushAndPen( Theme.Blue.WithAlpha( open ? 0.85f : hovered ? 0.35f : 0.16f ) );
		Paint.DrawRect( badge, 3 );

		Paint.SetPen( open ? Theme.TabBackground : Theme.Text.WithAlpha( 0.7f ) );
		Paint.SetDefaultFont( 6f, 700 );
		Paint.DrawText( badge, number.ToString(), TextFlag.Center );

		Paint.SetPen( Theme.TextControl.WithAlpha( open || hovered ? 1f : 0.6f ) );
		Paint.SetDefaultFont( 7f, 600 );
		Paint.DrawText( new Rect( header.Left + 23f, header.Top, header.Width - 23f, header.Height ), title.ToUpperInvariant(), TextFlag.LeftCenter );

		if ( open || string.IsNullOrEmpty( answer ) ) {
			return;
		}

		Paint.SetPen( Theme.Blue.WithAlpha( hovered ? 1f : 0.7f ) );
		Paint.DrawIcon( new Rect( header.Right - 17f, header.Top + 4f, 14f, 14f ), icon, 14f );

		Paint.SetPen( Theme.Text.WithAlpha( hovered ? 0.9f : 0.6f ) );
		Paint.SetDefaultFont( 7f, 400 );
		Paint.DrawText( new Rect( header.Left, header.Top, header.Width - 22f, header.Height ), answer, TextFlag.RightCenter );
	}
}