Editor/EffigyEditor/EffigyResultStrip.cs
using Editor;
using Effigy;
using Sandbox;
using System;

namespace Marionette.EditorTools;

/// <summary>
/// The ADD / REMOVE segmented control, floating under the tool strip.
///
/// WHY IT EXISTS. Result was a dropdown in the feature dialog, four rows down, reading "Auto" until
/// something changed it. That was enough to SET the mode and nowhere near enough to KNOW it: an
/// extrude that quietly added instead of cutting looks exactly like a boolean that failed, and the
/// gap between those two readings cost a whole session of blaming the engine's boolean for a
/// feature that was never asked to cut in the first place. A cut is the one operation here that
/// destroys material, and the mode it is in should be readable without looking for it.
///
/// THE DROPDOWN IS GONE NOW. This is the only control for Result — the dialog skips that parameter
/// (EffigyFeatureDialog.Rebuild) because a second, quieter copy of a mode indicator four rows down
/// is not redundancy, it is somewhere else to look.
///
/// FOUR SEGMENTS, NOT TWO. The mockup this is built from showed ADD | REMOVE, and two segments
/// cannot tell the truth: the default is Auto, so the honest answer to "which of these is lit" is
/// neither, and a control that lights ADD while the parameter says Auto is worse than no control.
/// Auto is also not a synonym for either one - it reads the sketch's attachment and can come out as
/// either - so it gets its own segment and says what it will resolve to underneath.
///
/// It is a VIEW of ExtrudeFeature.Result, not a second place that owns the value: clicking a segment
/// sets the one ChoiceParam the kernel reads and then goes through the same Edited path every other
/// parameter edit takes.
/// </summary>
internal sealed class EffigyResultStrip : Widget
{
	/// <summary>Segment size. Wider than it needs to be for the text: this is a mode indicator
	/// first and a button second, and it has to be legible from across the viewport.</summary>
	public const float SegmentWidth = 84f;

	public const float StripHeight = 30f;

	/// <summary>Corner radius. Half the height makes the ends semicircular - the pill in the
	/// mockup, rather than a rounded rectangle.</summary>
	private const float Radius = StripHeight * 0.5f;

	/// <summary>The parameter this is a view of. Null when no feature with a Result is open, which
	/// is also when the strip hides itself.</summary>
	private ChoiceParam _param;

	/// <summary>What Auto would actually do right now, as a Result index, or -1 if it cannot be
	/// worked out. Drawn under the Auto segment so "Auto" is not a shrug.</summary>
	private int _autoResolves = -1;

	private int _hovered = -1;

	/// <summary>Raised after a segment sets the parameter. The window hooks this to the same
	/// handler the dialog's own Edited runs, so a click here rebuilds exactly as one there does.
	/// </summary>
	public Action Changed { get; set; }

	public EffigyResultStrip( Widget parent ) : base( parent )
	{
		// Same two flags every floating widget in this tool sets. A plain Widget paints the system
		// background - a white slab on the 3D view - and without MouseTracking there is no hover.
		TranslucentBackground = true;
		NoSystemBackground = true;
		MouseTracking = true;

		Cursor = CursorShape.Finger;
		Visible = false;

		FixedHeight = StripHeight;
		FixedWidth = SegmentWidth * 4f;
	}

	/// <summary>
	/// Point the strip at whatever the dialog just opened, or at nothing.
	///
	/// Takes the FEATURE rather than the parameter so the decision about which features have a
	/// Result lives in one place, and so a feature type gaining one later needs no change here.
	/// </summary>
	public void Bind( Feature feature, Func<string, string> sketchHost )
	{
		_param = feature is SketchConsumingFeature consumer ? consumer.Result : null;

		FixedWidth = _param is null ? 0f : SegmentWidth * _param.Options.Length;

		_autoResolves = ResolveAuto( feature, sketchHost );

		Visible = _param is not null;
		_hovered = -1;

		Update();
	}

	/// <summary>
	/// What Auto would come out as, following the same rule the kernel does: a sketch on a face of
	/// an existing body adds to that body, a sketch on a global plane starts a new one.
	///
	/// Deliberately a READING of the rule rather than a call into it. The kernel decides this
	/// during a rebuild, with a FeatureContext that does not exist while a dialog is open, and
	/// building a fake one to ask would be a second implementation to drift. If the answer is not
	/// obvious from here it says nothing, which is the correct failure for a hint.
	/// </summary>
	private static int ResolveAuto( Feature feature, Func<string, string> sketchHost )
	{
		if ( feature is not SketchConsumingFeature consumer )
			return -1;

		if ( consumer.Sketch?.Value is not { Length: > 0 } sketchId )
			return -1;

		// A host means the sketch was placed on a face of something, which is the whole of the
		// rule. Auto never removes - see SketchConsumingFeature - so this is Add or New and
		// nothing else.
		return sketchHost?.Invoke( sketchId ) is { Length: > 0 } ? IndexAdd : IndexNew;
	}

	private const int IndexAuto = 0;
	private const int IndexNew = 1;
	private const int IndexAdd = 2;
	private const int IndexRemove = 3;

	/// <summary>
	/// Short, upper case, and NOT the parameter's own labels.
	///
	/// ChoiceParam's options are written for a dropdown - "Remove from the body it cuts into" - and
	/// they are the right text there, where there is room and no context. Here the whole point is
	/// that it reads at a glance from across the viewport.
	/// </summary>
	private static string Caption( int index ) => index switch
	{
		IndexAuto => "AUTO",
		IndexNew => "NEW",
		IndexAdd => "ADD",
		IndexRemove => "REMOVE",
		_ => "?"
	};

	/// <summary>
	/// REMOVE IS RED AND NOTHING ELSE IS. It is the only one of the four that destroys material,
	/// and the one whose being wrongly armed is expensive; the others are all additive and a
	/// mistake among them costs an undo. Blue for the rest matches every other selected state in
	/// this editor.
	/// </summary>
	private static Color Accent( int index ) => index == IndexRemove ? Theme.Red : Theme.Blue;

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;

		// The strip cannot decline to paint - an unpainted rect keeps whatever was in the buffer
		// keeps whatever was in the buffer. The pill's own body IS the background here.
		//
		// EVERYTHING BELOW IS BUILT FROM Width AND Height AND NOTHING ELSE. Rect.Left and
		// Rect.Bottom appear nowhere in this repo's working editor code, and an unproven member
		// name does not fail politely here - it fails the whole editor assembly and takes every
		// other tool with it. A widget's own rect starts at 0,0, so neither is needed: this is the
		// same idiom EffigyFeatureDialog paints with.
		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground.WithAlpha( 0.92f ) );
		Paint.DrawRect( new Rect( 0f, 0f, Width, Height ), Radius );

		if ( _param is null )
			return;

		var count = _param.Options.Length;
		var width = Width / count;

		for ( var i = 0; i < count; i++ )
		{
			var left = width * i;
			var rect = new Rect( left, 0f, width, Height );
			var selected = _param.Index == i;

			if ( selected )
			{
				// The selected segment is a filled lozenge inset inside the pill, so the pill's own
				// outline stays continuous behind it and the fill reads as sitting IN the control
				// rather than replacing part of it.
				Paint.ClearPen();
				Paint.SetBrush( Accent( i ).WithAlpha( 0.9f ) );
				Paint.DrawRect( rect.Shrink( 3f ), Radius - 3f );
			}
			else if ( _hovered == i )
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.Text.WithAlpha( 0.1f ) );
				Paint.DrawRect( rect.Shrink( 3f ), Radius - 3f );
			}

			// A divider between unselected neighbours only. Drawing one against a filled segment
			// puts a line through the accent for no reason - the fill is already the boundary.
			if ( i > 0 && !selected && _param.Index != i - 1 )
			{
				Paint.SetPen( Theme.Text.WithAlpha( 0.18f ), 1f );
				Paint.DrawLine( new Vector2( left, 7f ), new Vector2( left, Height - 7f ) );
			}

			Paint.SetDefaultFont( 11f, selected ? 700 : 500 );
			Paint.SetPen( selected ? Color.White : Theme.TextControl.WithAlpha( 0.75f ) );
			Paint.DrawText( rect, Caption( i ), TextFlag.Center );
		}

		PaintAutoHint( width );
	}

	/// <summary>
	/// A dot under Auto's segment, in the colour of whatever Auto is going to do.
	///
	/// Without it "Auto" is the one setting that does not say what it does, which is exactly the
	/// state that started all this - an extrude reading Auto, adding, and looking like a cut that
	/// silently failed.
	/// </summary>
	private void PaintAutoHint( float width )
	{
		if ( _autoResolves < 0 || _param.Index != IndexAuto )
			return;

		const float radius = 2.5f;

		var centre = new Vector2( width * 0.5f, Height - 6f );

		Paint.ClearPen();
		Paint.SetBrush( Accent( _autoResolves ) );

		// A RECT, not a centre and a radius. Both proven call sites in this repo (RigHelpBox,
		// RigTimeline) pass the bounding box; there is no centre+radius overload to reach for.
		Paint.DrawCircle( new Rect( centre.x - radius, centre.y - radius, radius * 2f, radius * 2f ) );
	}

	private int SegmentAt( Vector2 local )
	{
		if ( _param is null || Width <= 0f )
			return -1;

		var index = (int)(local.x / (Width / _param.Options.Length));

		return index >= 0 && index < _param.Options.Length ? index : -1;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		var was = _hovered;
		_hovered = SegmentAt( e.LocalPosition );

		if ( was != _hovered )
		{
			ToolTip = _hovered >= 0 && _param is not null ? _param.Options[_hovered] : "";
			Update();
		}
	}

	protected override void OnMouseLeave()
	{
		base.OnMouseLeave();

		_hovered = -1;
		Update();
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton || _param is null )
			return;

		// ACCEPTED WHATEVER HAPPENS, including a click that changes nothing. The strip floats on the
		// 3D canvas, and an unaccepted press there is a camera orbit that also scatters the view -
		// the same reason EffigyViewport excludes the tool strips from its hover test.
		e.Accepted = true;

		var index = SegmentAt( e.LocalPosition );

		if ( index < 0 || index == _param.Index )
			return;

		_param.Index = index;

		Update();

		Changed?.Invoke();
	}
}