DiamondPanel.Leaderboard.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using Sandbox;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	public bool LeaderboardOpen { get; private set; }
	public bool LeaderboardLoading { get; private set; }
	public IReadOnlyList<DiamondLeaderboard.Row> LeaderboardRows => leaderboardRows;
	public float LeaderboardScroll => leaderboardScroll;
	/// <summary>The board shown: high scores or longest times. It stays as chosen across visits.</summary>
	public DiamondLeaderboard.Mode LeaderboardMode { get; private set; }
	/// <summary>Generated players mixed into the fetched board; the host sets this from its debug property.</summary>
	public int LeaderboardTestPlayers { get; set; }
	bool leaderboardWasPaused;
	DiamondBoard leaderboardBoard;
	int leaderboardRequest;
	float leaderboardColorPhase;
	// 0 while HIGH SCORES is the heading, 1 for LONGEST TIME; between while the titles swap places.
	float leaderboardTitleBlend;
	List<DiamondLeaderboard.Row> leaderboardRows = new();
	// Boards fetched since the overlay opened, so toggling back is instant.
	Dictionary<DiamondLeaderboard.Mode, List<DiamondLeaderboard.Row>> leaderboardFetched;
	// Per-row display caches, filled as rows first scroll into view.
	string[] leaderboardNames, leaderboardScores;
	Texture[] leaderboardAvatars;
	float leaderboardScroll, leaderboardScrollTarget;
	bool leaderboardDragging;
	float leaderboardGrab;

	// Same unit scale as the other overlays, so text sizes match; only the frame is taller.
	const float LeaderboardWidth = 620, LeaderboardHeight = 672, LeaderboardRowHeight = 52;
	// The heading, and beneath it the other board's smaller, faded title, which switches to it.
	const float LeaderboardTitleY = 52, LeaderboardTitleSize = 52, LeaderboardOtherTitleY = 90, LeaderboardOtherTitleSize = 26;
	const float LeaderboardSwapDuration = 0.3f;
	// Palette indices cycled by rank for scores: yellow, blue, red, purple, green.
	static readonly int[] LeaderboardRankColors = [3, 1, 0, 4, 2];
	const float LeaderboardRankRight = 94, LeaderboardAvatarLeft = 108, LeaderboardAvatarSize = 36, LeaderboardNameLeft = 158;
	static Rect LeaderboardList => new( 32, 120, 556, 520 );
	static string LeaderboardTitle( DiamondLeaderboard.Mode mode ) => mode == DiamondLeaderboard.Mode.Time ? "LONGEST TIME" : "HIGH SCORES";
	DiamondLeaderboard.Mode OtherLeaderboardMode =>
		LeaderboardMode == DiamondLeaderboard.Mode.Score ? DiamondLeaderboard.Mode.Time : DiamondLeaderboard.Mode.Score;
	float LeaderboardTitleTarget => LeaderboardMode == DiamondLeaderboard.Mode.Time ? 1 : 0;
	/// <summary>The other board's title as a button, padded around its text.</summary>
	Rect LeaderboardOtherTitle
	{
		get
		{
			var text = DiamondScoreEffects.MeasureNumber( LeaderboardTitle( OtherLeaderboardMode ) ) * (LeaderboardOtherTitleSize / DiamondScoreEffects.PickupFontSize);
			return new Rect( LeaderboardWidth * 0.5f - text.x * 0.5f - 16, LeaderboardOtherTitleY - 20, text.x + 32, 40 );
		}
	}
	// Rows span the list, leaving room on the right only while the scrollbar shows.
	Rect LeaderboardRowsArea => new( LeaderboardList.Left, LeaderboardList.Top,
		LeaderboardList.Width - (LeaderboardMaxScroll > 0 ? 20 : 0), LeaderboardList.Height );
	static Rect LeaderboardTrack => new( LeaderboardList.Right - 14, LeaderboardList.Top + 8, 6, LeaderboardList.Height - 16 );
	static Rect LeaderboardTrackHit => new( LeaderboardTrack.Left - 10, LeaderboardList.Top, LeaderboardTrack.Width + 18, LeaderboardList.Height );
	static Rect LeaderboardFrame( Vector2 size )
	{
		float scale = MathF.Min( size.x * 0.5f / 620, size.y * 0.62f / 500 );
		var extent = new Vector2( LeaderboardWidth, LeaderboardHeight ) * scale;
		return new Rect( (size - extent) * 0.5f, extent );
	}
	static Vector2 LeaderboardLocal( Vector2 mouse, Rect frame ) => (mouse - frame.Position) / (frame.Width / LeaderboardWidth);
	float LeaderboardMaxScroll => MathF.Max( 0, leaderboardRows.Count * LeaderboardRowHeight - LeaderboardList.Height );
	float LeaderboardThumbLength
	{
		get
		{
			float content = leaderboardRows.Count * LeaderboardRowHeight;
			return content <= 0 ? LeaderboardTrack.Height : MathF.Max( 36, LeaderboardTrack.Height * MathF.Min( 1, LeaderboardList.Height / content ) );
		}
	}
	float LeaderboardThumbTop => LeaderboardTrack.Top + (LeaderboardMaxScroll <= 0 ? 0
		: (LeaderboardTrack.Height - LeaderboardThumbLength) * leaderboardScroll / LeaderboardMaxScroll);

	public void OpenLeaderboard()
	{
		if ( ModalOpen || Board is null ) return;
		leaderboardBoard = Board;
		leaderboardWasPaused = Board.Paused;
		if ( !Board.Paused ) Board.TogglePause();
		LeaderboardOpen = true;
		StopTitleAudio();
		(leaderboardFetched ??= new()).Clear();
		leaderboardTitleBlend = LeaderboardTitleTarget;
		ShowLeaderboardMode();
	}

	/// <summary>Switch between the high score and longest time boards, loading one not yet fetched this visit.</summary>
	public void SetLeaderboardMode( DiamondLeaderboard.Mode mode )
	{
		if ( mode == LeaderboardMode ) return;
		LeaderboardMode = mode;
		if ( LeaderboardOpen ) ShowLeaderboardMode();
	}

	public void ToggleLeaderboardMode() => SetLeaderboardMode( OtherLeaderboardMode );

	void ShowLeaderboardMode()
	{
		// A new request drops the other board's response still in flight.
		leaderboardRequest++;
		if ( leaderboardFetched is not null && leaderboardFetched.TryGetValue( LeaderboardMode, out var fetched ) )
		{
			SetLeaderboardRows( fetched );
			return;
		}
		SetLeaderboardRows( new List<DiamondLeaderboard.Row>() );
		LeaderboardLoading = true;
		_ = LoadLeaderboard( leaderboardRequest, LeaderboardMode );
	}

	public void CloseLeaderboard()
	{
		if ( !LeaderboardOpen ) return;
		if ( leaderboardBoard is not null && leaderboardBoard.Paused != leaderboardWasPaused ) leaderboardBoard.TogglePause();
		LeaderboardOpen = false;
		LeaderboardLoading = false;
		leaderboardBoard = null;
		// Drop a response still in flight.
		leaderboardRequest++;
		leaderboardFetched?.Clear();
		SetLeaderboardRows( new List<DiamondLeaderboard.Row>() );
	}

	async System.Threading.Tasks.Task LoadLeaderboard( int request, DiamondLeaderboard.Mode mode )
	{
		List<DiamondLeaderboard.Row> rows;
		try { rows = await DiamondLeaderboard.FetchTop( mode, LeaderboardTestPlayers ); }
		catch ( Exception e )
		{
			Log.Warning( $"Diamonds: leaderboard load failed ({e.Message})." );
			rows = new();
		}
		if ( !this.IsValid() || !LeaderboardOpen || request != leaderboardRequest ) return;
		(leaderboardFetched ??= new())[mode] = rows;
		SetLeaderboardRows( rows );
		LeaderboardLoading = false;
	}

	/// <summary>Show <paramref name="rows"/> from the top; tools use it to stage a board without the backend.</summary>
	public void SetLeaderboardRows( List<DiamondLeaderboard.Row> rows )
	{
		leaderboardRows = rows ?? new();
		leaderboardNames = new string[leaderboardRows.Count];
		leaderboardScores = new string[leaderboardRows.Count];
		leaderboardAvatars = new Texture[leaderboardRows.Count];
		leaderboardScroll = leaderboardScrollTarget = 0;
		leaderboardDragging = false;
		LeaderboardLoading = false;
	}

	public void UpdateLeaderboard( float delta, bool backspace )
	{
		if ( !LeaderboardOpen ) return;
		if ( backspace ) { CloseLeaderboard(); return; }
		delta = MathF.Max( 0, delta );
		leaderboardColorPhase = (leaderboardColorPhase + delta * 2.4f) % Palette.Length;
		// Linear progress, eased when drawn, so a click mid-swap reverses smoothly.
		leaderboardTitleBlend = LeaderboardTitleTarget > leaderboardTitleBlend
			? MathF.Min( LeaderboardTitleTarget, leaderboardTitleBlend + delta / LeaderboardSwapDuration )
			: MathF.Max( LeaderboardTitleTarget, leaderboardTitleBlend - delta / LeaderboardSwapDuration );
		if ( leaderboardDragging )
		{
			// Mouse-up normally ends the drag; losing the active state covers releases we never saw.
			if ( !HasActive ) leaderboardDragging = false;
			else DragLeaderboardThumb( LeaderboardLocal( MousePosition, LeaderboardFrame( Box.Rect.Size ) ).y );
		}
		leaderboardScrollTarget = Math.Clamp( leaderboardScrollTarget, 0, LeaderboardMaxScroll );
		// The thumb tracks the pointer exactly; wheel steps glide.
		leaderboardScroll = leaderboardDragging ? leaderboardScrollTarget
			: leaderboardScrollTarget + (leaderboardScroll - leaderboardScrollTarget) * MathF.Exp( -delta / 0.05f );
		if ( MathF.Abs( leaderboardScroll - leaderboardScrollTarget ) < 0.05f ) leaderboardScroll = leaderboardScrollTarget;
	}

	/// <summary>Scroll by <paramref name="rows"/> rows, as a mouse wheel step does.</summary>
	public void ScrollLeaderboard( float rows )
	{
		if ( !LeaderboardOpen || leaderboardDragging ) return;
		leaderboardScrollTarget = Math.Clamp( leaderboardScrollTarget + rows * LeaderboardRowHeight, 0, LeaderboardMaxScroll );
	}

	public override void OnMouseWheel( Vector2 value )
	{
		// The overlay owns the wheel; positive values scroll down.
		if ( LeaderboardOpen ) { ScrollLeaderboard( value.y * 2 ); return; }
		base.OnMouseWheel( value );
	}

	/// <summary>The X or an outside click closes; the other board's title switches to it; the scrollbar drags from its thumb or jumps to a track click.</summary>
	public void HandleLeaderboardClick( Vector2 mouse, Vector2 size )
	{
		if ( !LeaderboardOpen ) return;
		var frame = LeaderboardFrame( size );
		if ( TutorialClose( frame ).IsInside( mouse ) ) { PlayClickSound(); CloseLeaderboard(); return; }
		if ( !frame.IsInside( mouse ) ) { CloseLeaderboard(); return; }
		var local = LeaderboardLocal( mouse, frame );
		if ( LeaderboardOtherTitle.IsInside( local ) ) { PlayClickSound(); ToggleLeaderboardMode(); return; }
		if ( LeaderboardMaxScroll <= 0 || !LeaderboardTrackHit.IsInside( local ) ) return;
		float top = LeaderboardThumbTop, length = LeaderboardThumbLength;
		// A track click centers the thumb on the pointer, then keeps dragging from there.
		leaderboardGrab = local.y >= top && local.y <= top + length ? local.y - top : length * 0.5f;
		leaderboardDragging = true;
		DragLeaderboardThumb( local.y );
		leaderboardScroll = leaderboardScrollTarget;
	}

	void DragLeaderboardThumb( float y )
	{
		float travel = LeaderboardTrack.Height - LeaderboardThumbLength;
		if ( travel <= 0 ) return;
		leaderboardScrollTarget = Math.Clamp( (y - leaderboardGrab - LeaderboardTrack.Top) / travel, 0, 1 ) * LeaderboardMaxScroll;
	}

	void DrawLeaderboard( Painter p )
	{
		if ( !LeaderboardOpen ) return;
		using var scope = p.Scope();
		p.Fill = Color.Black.WithAlpha( 0.32f );
		p.Stroke = Stroke.None;
		p.Rect( p.Bounds );
		var frame = LeaderboardFrame( p.Bounds.Size );
		p.Fill = Color.Parse( "#0d151e" ).Value;
		p.Rect( frame );
		p.Clip( frame );
		float scale = frame.Width / LeaderboardWidth;
		if ( scale <= 0 ) return;
		DrawModalClose( p, frame );
		var mouse = LeaderboardLocal( MousePosition, frame );
		p.Translate( frame.Position );
		p.Scale( scale );
		bool time = LeaderboardMode == DiamondLeaderboard.Mode.Time;
		DrawLeaderboardTitles( p, mouse );
		var list = LeaderboardList;
		p.Fill = Color.Parse( "#080e15" ).Value;
		p.Stroke = Stroke.None;
		p.Rect( list );
		if ( LeaderboardLoading || leaderboardRows.Count == 0 )
		{
			DrawScoreNumber( p, LeaderboardLoading ? "LOADING..." : time ? "NO TIMES YET" : "NO SCORES YET", list.Center, 30, Muted * 0.8f );
			return;
		}
		using ( p.Scope() )
		{
			p.Clip( list );
			long me = (long)Game.SteamId;
			int first = Math.Max( 0, (int)(leaderboardScroll / LeaderboardRowHeight) );
			int last = Math.Min( leaderboardRows.Count - 1, (int)((leaderboardScroll + list.Height) / LeaderboardRowHeight) );
			for ( int i = first; i <= last; i++ )
				DrawLeaderboardRow( p, i, list.Top + i * LeaderboardRowHeight - leaderboardScroll, leaderboardRows[i].SteamId == me );
		}
		DrawLeaderboardScrollbar( p, mouse );
	}

	void DrawLeaderboardRow( Painter p, int index, float top, bool local )
	{
		var row = leaderboardRows[index];
		var area = LeaderboardRowsArea;
		var rect = new Rect( area.Left, top, area.Width, LeaderboardRowHeight );
		float centerY = rect.Center.y;
		p.Stroke = Stroke.None;
		if ( local )
		{
			p.Fill = ScoreColor( leaderboardColorPhase ).WithAlpha( 0.1f );
			p.Rect( rect );
		}
		else if ( index % 2 == 1 )
		{
			p.Fill = Ink.WithAlpha( 0.025f );
			p.Rect( rect );
		}
		var ink = local ? Ink : Ink * 0.85f;
		DrawScoreNumber( p, $"{index + 1}", new Vector2( LeaderboardRankRight, centerY ), 24, local ? Ink : Muted, align: 1 );

		var avatar = new Rect( LeaderboardAvatarLeft, centerY - LeaderboardAvatarSize * 0.5f, LeaderboardAvatarSize, LeaderboardAvatarSize );
		p.Fill = Color.Parse( "#16212b" ).Value;
		p.Rect( avatar );
		leaderboardAvatars[index] ??= Texture.LoadAvatar( row.SteamId, 64 );
		if ( leaderboardAvatars[index] is { IsLoaded: true } texture ) p.Texture( texture, avatar );

		string score = leaderboardScores[index] ??= DiamondLeaderboard.Format( LeaderboardMode, row.Value );
		float scoreRight = area.Right - 16;
		var scoreColor = Palette[LeaderboardRankColors[index % LeaderboardRankColors.Length]];
		bool time = LeaderboardMode == DiamondLeaderboard.Mode.Time;
		if ( time ) DrawTimeNumber( p, score, new Vector2( scoreRight, centerY ), 26, scoreColor, align: 1 );
		else DrawScoreNumber( p, score, new Vector2( scoreRight, centerY ), 26, scoreColor, align: 1 );

		float nameWidth = scoreRight - (time ? MeasureTimeNumber( score, 26 ) : MeasureScoreText( p, score, 26 ).x) - 20 - LeaderboardNameLeft;
		p.TextStyle = new TextStyle( "Oxanium", 22, ink ) { FontWeight = 600, Alignment = TextFlag.LeftCenter };
		string name = leaderboardNames[index] ??= Ellipsize( p, string.IsNullOrWhiteSpace( row.Name ) ? $"{row.SteamId}" : row.Name, nameWidth );
		p.Text( name, new Rect( LeaderboardNameLeft, rect.Top, MathF.Max( 1, nameWidth + 8 ), rect.Height ) );
	}

	/// <summary>Both titles, each placed, sized and colored by how selected it is, so they trade places when the board switches.</summary>
	void DrawLeaderboardTitles( Painter p, Vector2 mouse )
	{
		var button = LeaderboardOtherTitle;
		bool hover = button.IsInside( mouse );
		bool pressed = hover && HasActive;
		if ( hover )
		{
			p.Fill = Ink.WithAlpha( pressed ? 0.16f : 0.09f );
			p.Stroke = Stroke.None;
			p.Rect( button );
		}
		float time = leaderboardTitleBlend * leaderboardTitleBlend * (3 - 2 * leaderboardTitleBlend);
		var faded = pressed ? Ink.WithAlpha( 0.9f ) : hover ? Muted.WithAlpha( 0.9f ) : Muted.WithAlpha( 0.25f );
		// The heading draws last, over the title leaving it.
		if ( time >= 0.5f ) DrawLeaderboardTitle( p, DiamondLeaderboard.Mode.Score, 1 - time, faded );
		DrawLeaderboardTitle( p, DiamondLeaderboard.Mode.Time, time, faded );
		if ( time < 0.5f ) DrawLeaderboardTitle( p, DiamondLeaderboard.Mode.Score, 1 - time, faded );
	}

	void DrawLeaderboardTitle( Painter p, DiamondLeaderboard.Mode mode, float selected, Color faded )
	{
		float y = LeaderboardOtherTitleY + (LeaderboardTitleY - LeaderboardOtherTitleY) * selected;
		float size = LeaderboardOtherTitleSize + (LeaderboardTitleSize - LeaderboardOtherTitleSize) * selected;
		DrawScoreNumber( p, LeaderboardTitle( mode ), new Vector2( LeaderboardWidth * 0.5f, y ), size,
			Color.Lerp( faded, ScoreColor( leaderboardColorPhase ), selected ) );
	}

	void DrawLeaderboardScrollbar( Painter p, Vector2 mouse )
	{
		if ( LeaderboardMaxScroll <= 0 ) return;
		var track = LeaderboardTrack;
		bool hover = leaderboardDragging || LeaderboardTrackHit.IsInside( mouse );
		p.Stroke = Stroke.None;
		p.Fill = Ink.WithAlpha( hover ? 0.08f : 0.05f );
		p.Rect( track );
		p.Fill = Ink.WithAlpha( leaderboardDragging ? 0.6f : hover ? 0.45f : 0.28f );
		p.Rect( new Rect( track.Left, LeaderboardThumbTop, track.Width, LeaderboardThumbLength ) );
	}

	/// <summary>The longest prefix of <paramref name="text"/> that fits <paramref name="maxWidth"/> with an ellipsis, in the current text style.</summary>
	static string Ellipsize( Painter p, string text, float maxWidth )
	{
		if ( p.MeasureText( text ).x <= maxWidth ) return text;
		// Cut between whole text elements so surrogate pairs and combining marks stay intact.
		var info = new StringInfo( text );
		int low = 0, high = info.LengthInTextElements;
		while ( low < high )
		{
			int mid = (low + high + 1) / 2;
			if ( p.MeasureText( info.SubstringByTextElements( 0, mid ).TrimEnd() + "…" ).x <= maxWidth ) low = mid;
			else high = mid - 1;
		}
		return low == 0 ? "…" : info.SubstringByTextElements( 0, low ).TrimEnd() + "…";
	}
}