Editor/EffigyEditor/EffigyViewport.Lights.cs
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;

namespace Marionette.EditorTools;

/// <summary>
/// Viewport lighting: a full-bright modelling light, the studio sun used to judge materials,
/// and point lights you can drop in to see how a part reads under a lamp.
///
/// LIGHTS ARE VIEWPORT SCENERY, not features. They never enter the studio, never export, and
/// vanish when the window closes. A lamp that survived into a compiled vmdl would be a lighting
/// setup leaking into a mesh, which is the wrong file.
/// </summary>
/// <summary>What kind of lamp a placed light is. Point is the original and still the default:
/// it needs no aiming, so it is the one you can drop and drag without thinking about facing.</summary>
internal enum EffigyLightKind
{
	Point,
	Spot,
	Sun,
}

/// <summary>
/// A named arrangement of lamps, placed in one click.
///
/// WHY PRESETS AND NOT JUST "ADD LIGHT". Placing one lamp tells you very little: a single source
/// leaves half the part black, which is exactly the problem full bright exists to avoid. The
/// arrangements that actually show a shape are all several lamps in a known relationship, and
/// building one by hand means placing three lights and dragging each into position past the
/// geometry you are trying to look at. These are the standard ones, sized to whatever is on
/// screen.
/// </summary>
internal enum EffigyLightRig
{
	/// <summary>Key, fill and rim — the default portrait setup. Shows form without flattening it.</summary>
	ThreePoint,

	/// <summary>One bright source behind and above, so the silhouette lights up and the front goes
	/// dark. The setup for judging an outline rather than a surface.</summary>
	Rim,

	/// <summary>A single lamp overhead. Reads like a workbench, and it is the light that makes
	/// horizontal detail — panel lines, engraving — most legible.</summary>
	TopDown,

	/// <summary>One key light and nothing else. The harshest of the four, and the one that shows
	/// exactly where your normals are wrong.</summary>
	KeyOnly,
}

internal sealed partial class EffigyViewport
{
	/// <summary>
	/// Even light from every side, so a face is never in shadow while you model.
	///
	/// The studio rig this replaced is still there — one sun, a dim ambient — and it is the right
	/// light for judging a material against a game scene. It is the wrong light for modelling:
	/// the back of a part goes black and a face you are about to sketch on disappears. Full bright
	/// is the default; studio is the setting you turn on when you want to see the lighting.
	/// </summary>
	public bool FullBright
	{
		get => _fullBright;
		set
		{
			if ( _fullBright == value )
				return;

			_fullBright = value;
			ApplyLighting();
			LightingChanged?.Invoke();
		}
	}

	/// <summary>How many point lights are currently in the viewport.</summary>
	public int PlacedLightCount => _lights.Count;

	/// <summary>True while a placed light is selected (showing its move gizmo).</summary>
	public bool LightSelected => _selectedLight >= 0 && _selectedLight < _lights.Count;

	/// <summary>Fires when the mode or the set of lights changes, so Settings can reprint.</summary>
	public Action LightingChanged { get; set; }

	private bool _fullBright = true;

	private DirectionalLight _sun;
	private AmbientLight _ambient;

	private readonly List<PlacedLight> _lights = new();
	private int _selectedLight = -1;
	private bool _draggingLight;
	private Vector3 _lightDragStart;
	private Vector3 _lightDragDelta;

	/// <summary>Warm white, a little over 1 so it actually reads on a PBR material. Colour is
	/// the part you judge; intensity is just enough to see it.</summary>
	private static readonly Color PlacedLightColor = new Color( 1f, 0.96f, 0.88f ) * 4f;

	/// <summary>Colours for the three roles a rig lamp plays. The key is the warm one you judge
	/// the material under, the fill is cool and weak so the shadow side stays readable rather than
	/// going black, and the rim is bright and neutral because its whole job is an edge.</summary>
	private static readonly Color KeyColor = new Color( 1f, 0.95f, 0.86f ) * 5f;
	private static readonly Color FillColor = new Color( 0.82f, 0.88f, 1f ) * 1.6f;
	private static readonly Color RimColor = new Color( 1f, 0.98f, 0.94f ) * 7f;

	/// <summary>Typed as the abstract <see cref="Light"/> rather than PointLight, because a spot
	/// and a sun are not point lights and everything done to a lamp here — enable it, colour it —
	/// is on the base. Reach is kept alongside rather than read back off the component: only two
	/// of the three kinds HAVE a Radius, and the gizmo needs a number for all of them.</summary>
	private sealed class PlacedLight
	{
		public GameObject Object;
		public Light Light;
		public EffigyLightKind Kind;
		public float Reach;
	}

	/// <summary>Drop a point light. Kept by name because the View menu asks for exactly this, and a
	/// point light is still the sane default — it is the one kind with nothing to aim.</summary>
	public void AddPointLight() => AddLight( EffigyLightKind.Point );

	/// <summary>
	/// Drop a lamp in front of whatever is on screen, select it, and switch out of full bright so
	/// it actually lights the part. Adding a light you cannot see is how this would look broken.
	///
	/// Spots and suns are AIMED AT THE PART on the way in rather than left pointing along +x. A
	/// directional light's position means nothing to the renderer — only its rotation does — but
	/// it is still placed off to the side, so its gizmo has somewhere to be that is not inside
	/// the mesh.
	/// </summary>
	public void AddLight( EffigyLightKind kind )
	{
		using var scope = _canvas.Scene.Push();

		Spawn( kind, DefaultLightPosition(), CurrentFocus(), PlacedLightColor );

		_selectedLight = _lights.Count - 1;
		_draggingLight = false;

		DeselectOrigin();
		DeselectBone();

		// Full bright would swallow the lamp. Flip it off here rather than asking the caller to
		// remember — every route that adds a light lands in this method.
		if ( _fullBright )
			_fullBright = false;

		ApplyLighting();
		LightingChanged?.Invoke();
	}

	/// <summary>
	/// Replace whatever is placed with a named arrangement, sized to the part on screen.
	///
	/// REPLACE RATHER THAN ADD. A rig is a complete answer to "how is this lit"; dropping a second
	/// one on top of the first gives you six lamps in two conflicting setups and no way to tell
	/// which belongs to which. Clearing first makes picking a different rig one click that always
	/// lands somewhere predictable.
	/// </summary>
	public void ApplyRig( EffigyLightRig rig )
	{
		using var scope = _canvas.Scene.Push();

		foreach ( var placed in _lights )
			placed.Object?.Destroy();

		_lights.Clear();

		var center = CurrentFocus();
		var r = LightSceneRadius();
		var cam = _camera.WorldRotation;

		// POSITIONS ARE IN CAMERA SPACE, not world space. A rig anchored to world axes is a
		// different rig depending on which way you happen to be orbiting — the key light ends up
		// behind the part about half the time. Relative to the camera, "key up and to the right"
		// means the same thing from every angle, and that is what makes these worth one click.
		var right = cam.Right;
		var up = cam.Up;
		var forward = cam.Forward;

		switch ( rig )
		{
			case EffigyLightRig.ThreePoint:
				Spawn( EffigyLightKind.Spot, center + right * r * 1.6f + up * r * 1.2f - forward * r * 1.4f,
					center, KeyColor );
				Spawn( EffigyLightKind.Point, center - right * r * 1.8f + up * r * 0.3f - forward * r * 1.1f,
					center, FillColor );
				Spawn( EffigyLightKind.Spot, center - right * r * 0.7f + up * r * 1.5f + forward * r * 1.9f,
					center, RimColor );
				break;

			case EffigyLightRig.Rim:
				Spawn( EffigyLightKind.Spot, center + right * r * 1.1f + up * r * 1.3f + forward * r * 2f,
					center, RimColor );
				Spawn( EffigyLightKind.Point, center - right * r * 1.5f + up * r * 0.4f - forward * r * 1.3f,
					center, FillColor * 0.5f );
				break;

			case EffigyLightRig.TopDown:
				// Straight down in WORLD terms, deliberately breaking the camera-space rule above:
				// "overhead" means overhead, and a top light that tilts as you orbit is not one.
				Spawn( EffigyLightKind.Spot, center + Vector3.Up * r * 2.6f, center, KeyColor );
				break;

			case EffigyLightRig.KeyOnly:
				Spawn( EffigyLightKind.Spot, center + right * r * 1.6f + up * r * 1.2f - forward * r * 1.4f,
					center, KeyColor );
				break;
		}

		_selectedLight = -1;
		_draggingLight = false;

		if ( _fullBright )
			_fullBright = false;

		ApplyLighting();
		LightingChanged?.Invoke();
	}

	/// <summary>Build one lamp and add it to the list. Deliberately does not touch selection, full
	/// bright or the event: a rig fires those once for the whole set rather than once per lamp.</summary>
	private void Spawn( EffigyLightKind kind, Vector3 position, Vector3 aimAt, Color color )
	{
		var go = new GameObject( true, "effigy_light" );
		var reach = DefaultLightRadius();

		go.WorldPosition = position;

		// Aiming is harmless on a point light and essential on the other two, so it is done for
		// all three rather than guarded per kind. A zero-length direction would give a NaN
		// rotation, so a lamp sitting exactly on the focus keeps identity instead.
		var toTarget = aimAt - position;

		if ( !toTarget.IsNearlyZero() )
			go.WorldRotation = Rotation.LookAt( toTarget.Normal );

		Light light = kind switch
		{
			EffigyLightKind.Spot => BuildSpot( go, reach ),
			EffigyLightKind.Sun => go.GetOrAddComponent<DirectionalLight>( false ),
			_ => BuildPoint( go, reach ),
		};

		light.LightColor = color;
		light.Shadows = true;
		light.Enabled = true;

		_lights.Add( new PlacedLight { Object = go, Light = light, Kind = kind, Reach = reach } );
	}

	private static Light BuildPoint( GameObject go, float reach )
	{
		var light = go.GetOrAddComponent<PointLight>( false );
		light.Radius = reach;
		return light;
	}

	private static Light BuildSpot( GameObject go, float reach )
	{
		var light = go.GetOrAddComponent<SpotLight>( false );
		light.Radius = reach;

		// Wide enough to cover a part framed in the viewport from the distance the rigs place it
		// at. A tight cone reads as a spotlight EFFECT rather than as lighting, which is not what
		// these are for.
		light.ConeInner = 25f;
		light.ConeOuter = 45f;
		return light;
	}

	/// <summary>Remove the selected light, or do nothing if none is selected.</summary>
	public void RemoveSelectedLight()
	{
		if ( !LightSelected )
			return;

		using var scope = _canvas.Scene.Push();

		_lights[_selectedLight].Object?.Destroy();
		_lights.RemoveAt( _selectedLight );
		_selectedLight = -1;
		_draggingLight = false;

		LightingChanged?.Invoke();
	}

	/// <summary>Remove every placed light.</summary>
	public void ClearLights()
	{
		if ( _lights.Count == 0 )
			return;

		using var scope = _canvas.Scene.Push();

		foreach ( var placed in _lights )
			placed.Object?.Destroy();

		_lights.Clear();
		_selectedLight = -1;
		_draggingLight = false;

		LightingChanged?.Invoke();
	}

	public void DeselectLight()
	{
		if ( _selectedLight < 0 )
			return;

		_selectedLight = -1;
		_draggingLight = false;
	}

	/// <summary>
	/// Apply whichever lighting the setting asks for.
	///
	/// Full bright: sun off, ambient white, placed lights off. Studio: the runtime sun and
	/// ambient from <see cref="BuildRuntimeLighting"/>, placed lights on. The cubemap and the
	/// tonemapper stay in both — they are why a material here matches a material in game, and
	/// full bright is about the key light, not about throwing the rest of the rig away.
	/// </summary>
	private void ApplyLighting()
	{
		if ( _sun.IsValid() )
			_sun.Enabled = !_fullBright;

		if ( _ambient.IsValid() )
		{
			_ambient.Color = _fullBright
				? Color.White
				: new Color( 0.237f, 0.237f, 0.237f, 1f );
			_ambient.Enabled = true;
		}

		foreach ( var placed in _lights )
		{
			if ( placed.Light.IsValid() )
				placed.Light.Enabled = !_fullBright;
		}
	}

	private Vector3 DefaultLightPosition()
	{
		var center = CurrentFocus();
		var radius = LightSceneRadius();
		var cam = _camera.WorldRotation;

		// Off to the camera's right and up, slightly in front of the part, so the bulb is not
		// born inside the mesh and is the first thing you see after adding it.
		return center + cam.Right * radius + cam.Up * (radius * 0.55f) + cam.Forward * (-radius * 0.15f);
	}

	private float DefaultLightRadius()
	{
		// Reach past the part with room to spare. A radius that ends on the surface lights one
		// face and looks like the lamp did nothing.
		return Math.Clamp( LightSceneRadius() * 8f, 64f, 2048f );
	}

	private float LightSceneRadius()
	{
		if ( _renderer.IsValid() && _renderer.Model is { } model )
			return MathF.Max( model.Bounds.Size.Length * 0.5f, 8f );

		return 32f;
	}

	/// <summary>
	/// Draw each placed light as a bulb you can click, and a move gizmo on the selected one.
	/// Hidden while sketching or picking — same reason the origin is, it would steal the click.
	/// </summary>
	private void DrawViewportLights()
	{
		for ( var i = 0; i < _lights.Count; i++ )
		{
			var placed = _lights[i];

			if ( !placed.Object.IsValid() || !placed.Light.IsValid() )
				continue;

			DrawOneLight( i, placed );
		}
	}

	private void DrawOneLight( int index, PlacedLight placed )
	{
		var position = placed.Object.WorldPosition;
		var radius = WorldRadiusAt( position, 4.5f );
		var selected = index == _selectedLight;
		var live = !_fullBright;

		using var scope = Gizmo.Scope( $"light-{index}", new Transform( position ) );

		if ( selected )
		{
			using ( Gizmo.Scope( "light-move", new Transform( Vector3.Zero ) ) )
			{
				Gizmo.Hitbox.DepthBias = 0.01f;

				if ( Gizmo.Control.Position( $"light-{index}-pos", Vector3.Zero, out var displacement, Rotation.Identity ) )
				{
					if ( !_draggingLight )
					{
						_draggingLight = true;
						_lightDragStart = position;
						_lightDragDelta = Vector3.Zero;
					}

					_lightDragDelta += displacement;
					placed.Object.WorldPosition = _lightDragStart + _lightDragDelta;
				}
				else if ( _draggingLight && _selectedLight == index )
				{
					_draggingLight = false;
				}
			}

			Gizmo.Draw.IgnoreDepth = true;
			Gizmo.Draw.Color = new Color( 1f, 0.72f, 0.22f, 1f );
			Gizmo.Draw.SolidSphere( 0f, radius * 1.35f, 12, 12 );
			Gizmo.Draw.Color = new Color( 1f, 0.72f, 0.22f, 0.35f );

			// A sun has no radius to draw — its reach is the whole scene — so the falloff sphere
			// would be a lie. It gets the aim line below instead, which is the only thing about a
			// directional light you can actually move.
			if ( placed.Kind != EffigyLightKind.Sun )
				Gizmo.Draw.LineSphere( new Sphere( Vector3.Zero, placed.Reach ), 4 );

			DrawLightAim( placed );

			Gizmo.Draw.IgnoreDepth = false;

			return;
		}

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = new Color( 1f, 0.72f, 0.22f, live ? 0.9f : 0.35f );
		Gizmo.Draw.SolidSphere( 0f, radius, 10, 10 );
		Gizmo.Draw.IgnoreDepth = false;

		Gizmo.Hitbox.DepthBias = 0.01f;
		Gizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, radius * 2.6f ) );

		if ( !Gizmo.IsHovered )
			return;

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = new Color( 1f, 0.72f, 0.22f, 0.35f );
		Gizmo.Draw.SolidSphere( 0f, radius * 2.6f, 10, 10 );
		Gizmo.Draw.IgnoreDepth = false;

		if ( Gizmo.WasLeftMousePressed )
		{
			_selectedLight = index;
			_draggingLight = false;
			DeselectOrigin();
			DeselectBone();
		}
	}

	/// <summary>
	/// A short line out of the bulb along the way it faces, for the two kinds where facing is the
	/// whole setting.
	///
	/// A point light is left alone: it throws light every way, so a line out of one side of it
	/// would claim a direction it does not have. Drawn inside the caller's gizmo scope, so the
	/// coordinates are local to the lamp and the direction is simply the object's own forward
	/// brought back out of world space.
	/// </summary>
	private void DrawLightAim( PlacedLight placed )
	{
		if ( placed.Kind == EffigyLightKind.Point || !placed.Object.IsValid() )
			return;

		var length = WorldRadiusAt( placed.Object.WorldPosition, 4.5f ) * 6f;
		var direction = placed.Object.WorldRotation.Forward;

		Gizmo.Draw.Color = new Color( 1f, 0.72f, 0.22f, 0.55f );
		Gizmo.Draw.Line( Vector3.Zero, direction * length );
	}

}