UI/LocalReplaysScreen.razor
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@using System.Collections.Generic
@inherits VirtualListPanel
@namespace BlockParty
@attribute [StyleSheet( "LocalReplaysScreen.razor.scss" )]

<root style="opacity: @(Stage?.FadeAlpha ?? 0f)">
	@{ if ( Stage is null ) return; }
	@* Header row is the same width as the list (the centred content square), so the title sits centred
	   and the IMPORT button pins to the square's right edge — not out in the letterbox. The old footer
	   IMPORT is gone — the footer holds only the MAIN MENU button. *@
	<PixelTooltip @ref="Tooltip"></PixelTooltip>
	<div class="header-row">
		<label class="header">LOCAL REPLAYS</label>
		<div class="actions">
			<button class="favorite-filter @(_favoritesOnly ? "active" : "")" onclick=@ToggleFavoritesOnly
				onmouseover=@Tip( () => LocalReplays.FavoritesOnlyFilter
					? "Showing favorites only"
					: "Showing all replays" )>
				<div class="icon">@(_favoritesOnly ? "favorite" : "favorite_border")</div>
			</button>
			<button class="import-button" onclick=@OpenImport
				onmouseover=@Tip( "Add replay from pasted share code" )>
				<label class="text">IMPORT</label>
				<div class="icon">file_download</div>
			</button>
		</div>
	</div>

	<div class="list">
		@{
			// Visible window + row placement come from VirtualListPanel.
			int first = FirstVisible;
			int last = LastVisible;
		}
		@if ( _entries.Count == 0 )
		{
			<label class="info">@(_favoritesOnly ? "NO FAVORITES YET" : "NO REPLAYS YET")</label>
		}
		else
		{
			@for ( int i = first; i < last; i++ )
			{
				var entry = _entries[i];
				var run = entry.Run;
				var idx = i;
				var top = RowTop( i );
				bool win = run.Victory;
				bool canReplay = run.CanReplay;
				<div class="row @(i % 2 == 1 ? "even" : "") @(i == Selected ? "selected" : "")" style="top: @(top)px;"
					onmouseover=@(() => SelectRow( idx )) onclick=@(() => SelectRow( idx ))>
					<SteamAvatar [email protected] Size=@(50) OnHover=@Tip( "Steam profile" )></SteamAvatar>
					<LiteralLabel class=@(entry.SteamId == (long)Game.SteamId ? "name own" : "name") Text=@NameFor( entry ) />
					<div class="level-col" onmouseover=@Tip( () => RowLevelTip( idx ) )>
						@if ( run.Level is not null )
						{
							<LevelNodePreview [email protected] CompactTag=@true StaticThumbnail=@true></LevelNodePreview>
						}
					</div>
					<label class="date">@FormatDate( entry.RecordedAt )</label>
					<label class="dur">@FormatTime( run.TimeSeconds )</label>
					<div class="char-col">
						<div class="icon-glow"></div>
						@if ( run.Character.Partner is CharacterDef partner )
						{
							<div class="character rear" style="@CharacterPreviewStyle( partner )"></div>
						}
						<div class="character @(run.Character.Partner is null ? "" : "front")" style="@CharacterPreviewStyle( run.Character )"></div>
					</div>
					<div class="blocks-col">
						<BlockTally Blocks=@(run.DecodeBlocks()) />
					</div>
					<label class="score @(!win ? "non-victory" : "")">@run.FinalScore</label>
					@if ( canReplay )
					{
						<button class="replay" onclick=@(() => StartReplay( entry ))
							onmouseover=@Tip( "Watch replay" )>▶</button>
					}
					<button class="favorite @(entry.IsFavorite ? "active" : "")" onclick=@(() => ToggleFavorite( entry ))
						onmouseover=@Tip( () => RowFavoriteTip( idx ) )>
						<div class="icon">@(entry.IsFavorite ? "favorite" : "favorite_border")</div>
					</button>
					<button class="delete" onclick=@(() => DeleteEntry( entry ))
						onmouseover=@Tip( "Delete replay" )>X</button>
				</div>
			}

			@if ( ShowScrollbar )
			{
				<div class="scrollbar" style="top: @(ThumbTop)px; height: @(ThumbHeight)px;"></div>
			}

			@* Hidden while the MAIN MENU button is focused; its own cube shows in the footer instead. *@
			@if ( !BackSelected )
			{
				<div class="selector" style="top: @(SelectorTop)px; background-position: @(-SelectorFrame * 100)% 0%;"></div>
			}
		}
	</div>

	@* <label class="hint">SPACE: WATCH REPLAY · W/S: SELECT · ESC: BACK</label> *@
	<div class="footer">
		@* MAIN MENU joins the W/S cycle as the BACK pseudo-item (see VirtualListPanel.HasBackItem):
		   it takes the selector cube + highlight when focused; the cube keeps a fixed lane so the
		   button doesn't shift. *@
		<div class="selector @(BackSelected ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
		<button class="back @(BackSelected ? "selected" : "")" onmouseover=@SelectBack onclick=@(() => { Audio.PlaySfx( SfxType.MenuStart ); Stage.GoBack(); })>MAIN MENU</button>
	</div>

	@if ( _showImport )
	{
		<ReplayCodeDialog Title="IMPORT REPLAY CODE" ConfirmLabel="IMPORT" Error=@_importError
			OnConfirm=@((string code) => ConfirmImport( code )) OnCancel=@CloseImport />
	}
</root>

@code
{
	public LocalReplaysStage Stage { get; set; }
	public bool ImportOpen => _showImport;

	private static string CharacterPreviewStyle( CharacterDef character )
	{
		float size = 40f * character.PreviewScale;
		float offset = (40f - size) * 0.5f;
		return FormattableString.Invariant(
			$"left:{offset:0.###}px;top:{offset:0.###}px;width:{size:0.###}px;height:{size:0.###}px;background-image:url( {character.PreviewImage} );" );
	}

	private List<LocalReplayEntry> _entries = new();
	private bool _showImport;
	private string _importError = "";
	private bool _favoritesOnly => LocalReplays.FavoritesOnlyFilter;

	// ── hover tooltips (same mechanism as LevelEditorHud, which documents it in full) ─────────────────
	private PixelTooltip Tooltip { get; set; }
	private Panel _tipPanel;
	private Func<string> _tipText;

	/// <summary>Fixed tooltip text.</summary>
	private Action<PanelEvent> Tip( string text )
		=> e => { _tipPanel = e.This; _tipText = () => text; };

	/// <summary>Tooltip text that can change while the control stays hovered, resolved per frame: clicking
	/// a hovered toggle re-attaches this handler without firing a fresh mouseover, so a snapshot would
	/// keep describing the state just left.</summary>
	private Action<PanelEvent> Tip( Func<string> text )
		=> e => { _tipPanel = e.This; _tipText = text; };

	/// <summary>Row favourite tip, resolved from the ROW INDEX: toggling a favourite (or a delete) can
	/// drop the row and shift the list, and razor reuses the button panel for whatever entry lands in
	/// this slot next — so a captured entry would describe the wrong run.</summary>
	private string RowFavoriteTip( int index )
		=> index >= 0 && index < _entries.Count
			? ( _entries[index].IsFavorite ? "Remove from favorites" : "Mark as favorite" )
			: null;

	// Resolve from the current row, just like favorite tips: scrolling/filtering/deleting can
	// reuse the hovered panel for another replay. Unnamed stages intentionally have no tooltip.
	private string RowLevelTip( int index )
	{
		string name = index >= 0 && index < _entries.Count ? _entries[index].Run?.Level?.Name : null;
		return string.IsNullOrWhiteSpace( name ) ? null : name;
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();
		string text = _tipPanel.IsValid() && _tipPanel.HasHovered ? _tipText?.Invoke() : null;
		if ( string.IsNullOrEmpty( text ) ) Tooltip?.Hide();
		else Tooltip?.Show( text );
	}

	// Row count for the virtual-list base (scroll/selection/selector all live in VirtualListPanel).
	protected override int ItemCount => _entries.Count;

	// The footer MAIN MENU button joins the W/S selection cycle as a pseudo-item (index -1): it is
	// focused only when there are no replay rows, and wraps around above the first row / below the last.
	protected override bool HasBackItem => true;

	// Freeze W/S list navigation while the import dialog owns input, so the selection doesn't move
	// behind the modal (the dialog is capturing the keystrokes for the code field).
	protected override void HandleNavigation( float dt )
	{
		if ( _showImport )
			return;

		base.HandleNavigation( dt );
	}

	protected override void OnStart()
	{
		base.OnStart();
		RefreshEntries();
		if ( _entries.Count > 0 )
		{
			// Reopen focused on the row we just watched when returning from a replay; SelectInitial
			// clamps, so a now-shorter list (a deleted run) still lands on a valid row.
			int initial = Stage?.ReturnSelection ?? -1;
			SelectInitial( initial >= 0 ? initial : 0 );
		}
		else
			SelectBackInitial();
	}

	private void RefreshEntries()
	{
		// Only show runs that are actually replayable on the current build (CanReplay checks the
		// recorded SimVersion against the supported range); stale-version runs would just desync. They
		// stay in the archive though — a version gate that revives their version shows them again.
		_entries = LocalReplays.All
			.Where( e => e.Run?.CanReplay == true && (!_favoritesOnly || e.IsFavorite) )
			.ToList();   // newest first
	}

	// Launch the selected run's replay; tagged as the local player so the HUD shows "you".
	private void StartReplay( LocalReplayEntry entry )
	{
		if ( entry?.Run is null || !entry.Run.CanReplay )
			return;
		Audio.PlaySfx( SfxType.MenuStart );
		// Remember this row so exiting the replay reopens the archive with it focused.
		int index = _entries.IndexOf( entry );
		GameManager.Instance?.StartReplay( entry.Run, new ReplaySubmitter( entry.SteamId, NameFor( entry ), entry.CountryCode ?? "", entry.RecordedAt ), fromLocalReplays: true, returnSelectionIndex: index );
	}

	private void ToggleFavoritesOnly()
	{
		Audio.PlaySfx( SfxType.MenuBlip );
		LocalReplays.FavoritesOnlyFilter = !LocalReplays.FavoritesOnlyFilter;
		RefreshEntries();
		if ( _entries.Count > 0 )
			ResetToTop();
		else
			SelectBackInitial();
		StateHasChanged();
	}

	private void ToggleFavorite( LocalReplayEntry entry )
	{
		Audio.PlaySfx( SfxType.MenuBlip );
		int index = _entries.IndexOf( entry );
		LocalReplays.ToggleFavorite( entry );

		if ( _favoritesOnly && index >= 0 && !entry.IsFavorite )
		{
			_entries.RemoveAt( index );
			ClampSelectionAfterRemoval();
		}

		StateHasChanged();
	}

	// Permanently delete one archived run from disk and drop it from the visible list, keeping the
	// selection on a valid row.
	private void DeleteEntry( LocalReplayEntry entry )
	{
		int index = _entries.IndexOf( entry );
		if ( index < 0 )
			return;

		Audio.PlaySfx( SfxType.MenuBlip );
		LocalReplays.Remove( entry );
		_entries.RemoveAt( index );

		ClampSelectionAfterRemoval();
		StateHasChanged();
	}

	private void OpenImport()
	{
		Audio.PlaySfx( SfxType.MenuStart );
		_showImport = true;
		_importError = "";
		StateHasChanged();
	}

	private void CloseImport()
	{
		_showImport = false;
		_importError = "";
		StateHasChanged();
	}

	private void ConfirmImport( string code )
	{
		if ( !LocalReplays.TryImportCode( code ) )
		{
			_importError = "INVALID REPLAY CODE";
			StateHasChanged();
			return;
		}

		RefreshEntries();
		if ( _entries.Count > 0 )
			ResetToTop();
		else
			SelectBackInitial();
		CloseImport();
	}

	public void LaunchSelected()
	{
		if ( BackSelected )
		{
			Stage?.GoBack();
			return;
		}

		if ( Selected < 0 || Selected >= _entries.Count )
			return;
		StartReplay( _entries[Selected] );
	}

	// dd MMM HH:mm, e.g. "28 Jun 14:05". Local time.
	private static string FormatDate( DateTimeOffset t ) => t.LocalDateTime.ToString( "dd MMM HH:mm" );

	private static string NameFor( LocalReplayEntry entry ) =>
		!string.IsNullOrWhiteSpace( entry.DisplayName ) ? entry.DisplayName : new Friend( entry.SteamId ).Name;

	private static string FormatTime( float seconds )
	{
		if ( seconds < 0f ) seconds = 0f;
		int total = (int)MathF.Floor( seconds );
		return $"{total / 60:00}:{total % 60:00}";
	}

	protected override int BuildHash() => System.HashCode.Combine(
		_entries.Count,
		(int)ScrollPos,
		Selected,
		System.HashCode.Combine( (int)(SelectorPos * 8f), SelectorFrame ),
		System.HashCode.Combine( (int)((Stage?.FadeAlpha ?? 0f) * 20f), _showImport, _importError, _favoritesOnly, LocalReplays.Version ) );
}