UI/WorkshopScreen.razor
@using System
@using System.Collections.Generic
@using System.Linq
@using System.Threading.Tasks
@using Sandbox
@using Sandbox.UI
@inherits PanelComponent
@namespace BlockParty
@attribute [StyleSheet( "WorkshopScreen.razor.scss" )]

@* Workshop level browser: search + sort over this game's published levels (Storage.Query), a grid of
   thumbnail tiles, click-to-play (installs first), and a per-tile trophy chip opening that level's
   leaderboard. Queries degrade quietly — no Steam / no results just show a status label. *@
<root style="opacity: @(Stage?.FadeAlpha ?? 0f)">
	@{ if ( Stage is null ) return; }
	<PixelTooltip @ref="Tooltip"></PixelTooltip>
	<label class="header">WORKSHOP</label>

	@* Level editor entry, upper-left (mirrors the character slot) — the title row no longer carries
	   EDITOR; creations live here, next to where they get published and played. *@
	<button class="square-btn i-edit editor-slot" onclick=@Click( () => LevelEditorStage.OpenLevelEditor(), SfxType.MenuStart )
		onmouseover=@Tip( "Level Editor" )>
		<div class="img"></div>
	</button>

	@* Character picker, upper-right like the daily/level-select screens: picks the character workshop
	   runs play as (a level's forced character still overrides at run start, like everywhere). *@
	<div class="character-slot">
		<CharacterPicker CenterOverlayOnScreen=@true OnOpenChanged=@OnCharacterPickerOpenChanged
			@ref="CharacterPickerPanel"></CharacterPicker>
	</div>

	<div class="searchbar">
		<div class="search-box">
			<EditorTextEntry class="search-entry" Value=@_search Placeholder="SEARCH"
				OnTextEdited=@((string v) => OnSearchEdited( v )) @ref="SearchEntry"></EditorTextEntry>
			@if ( !string.IsNullOrEmpty( _search ) )
			{
				<button class="search-clear" onclick=@Click( ClearSearchBox )><div class="icon">close</div></button>
			}
		</div>
		<button class="sort on" onclick=@Click( CycleSort )>@SortLabel</button>
	</div>

	<div class="grid">
		@if ( _loading && _items.Count == 0 )
		{
			<div class="notice"><label>LOADING...</label></div>
		}
		else if ( !string.IsNullOrEmpty( _status ) && _items.Count == 0 )
		{
			<div class="notice"><label>@_status</label></div>
		}
		else if ( _items.Count == 0 )
		{
			<div class="notice"><label>NO RESULTS</label></div>
		}
		else
		{
			@foreach ( var item in _items )
			{
				var it = item;   // capture for the per-tile handlers
				@* The trophy and web-link chips are SIBLINGS of the clickable face, not children —
				   s&box panel events bubble, so nesting them would make their clicks also start the
				   run. The web chip just overlays the face's top-right corner via absolute position. *@
				<div class="tile">
					@* Live animated schematic (same as every local picker), rebuilt from the level JSON
					   embedded in the item's metadata — or from the installed local copy for levels
					   already downloaded. Only levels too big for Steam's metadata cap AND not yet
					   installed fall back to the uploaded thumbnail PNG (its own child div, so a
					   reused tile can never keep a stale background image). *@
					<button class="face" onclick=@Click( () => PlayItem( it ), SfxType.MenuStart )>
						@{ var previewDef = PreviewDef( it ); }
						@if ( previewDef is not null )
						{
							<LevelNodePreview Level=@previewDef></LevelNodePreview>
							@if ( Characters.TryGet( previewDef.ForcedCharacterId, out var forcedCharacter ) )
							{
								<div class="tile-forced-character @(forcedCharacter.Partner is null ? "" : "paired")" style="@ForcedCharacterStyle( forcedCharacter )">
									<div class="icon-glow"></div>
									@if ( forcedCharacter.Partner is CharacterDef forcedPartner )
									{
										<div class="sprite rear" style="background-image:url( @forcedPartner.PreviewImage );"></div>
									}
									<div class="sprite front" style="background-image:url( @forcedCharacter.PreviewImage );"></div>
								</div>
							}
						}
						else
						{
							<div class="img" style="background-image: url( @(it.Preview ?? "") )"></div>
						}
						@if ( it.VotesUp > 0 )
						{
							<div class="votes">
								<div class="icon"></div>
								<label>@it.VotesUp</label>
							</div>
						}
						@if ( _busyItem == it.Id )
						{
							<div class="busy"><label>@(_busyIsBoard ? "LOADING..." : "INSTALLING...")</label></div>
						}
						else if ( _failedItem == it.Id )
						{
							<div class="busy failed"><label>FAILED</label></div>
						}
					</button>
					@* Opens the item's Steam Workshop page in a web browser. Not a button: the engine
					   only opens web links from a rich label's <a href> (see WebLinkLabel), so the chip
					   holds an anchor — one near-chip-sized icon glyph, since only the glyph is
					   clickable. The chip visuals (background/border/hover) live on this WRAPPER, never
					   on the label: the engine bakes the label's own computed background into the anchor
					   span's texture on hover-time rebuilds, and it sticks there after un-hover because
					   nothing dirties the text block once the mouse leaves. *@
					<div class="web" onmouseover=@Tip( "Steam Workshop page" )>
						<WebLinkLabel IsRich=@true Text=@WebLinkHtml( it )></WebLinkLabel>
					</div>
					@* Uploader's avatar over the face's bottom-right corner, linking to their workshop levels
					   — also a sibling of the face, so its click can't bubble into the play action. *@
					@if ( it.Owner is not null && (ulong)it.Owner.Id != 0 )
					{
						<SteamAvatar SteamId=@((long)(ulong)it.Owner.Id) Size=@(40) [email protected]( it.Owner.Id )
							OnHover=@Tip( "Author's Workshop levels" )></SteamAvatar>
					}
					<div class="caption">
						<div class="text">
							<label class="name">@(string.IsNullOrWhiteSpace( it.Title ) ? "UNTITLED" : it.Title)</label>
							<LiteralLabel class="author" Text=@(it.Owner?.Name ?? "") />
						</div>
						<button class="board" onclick=@Click( () => OpenBoard( it ) ) onmouseover=@Tip( "Leaderboard" )>
							<div class="icon">emoji_events</div>
						</button>
					</div>
				</div>
			}

			@if ( _result?.HasMoreResults() == true )
			{
				<div class="load-more-row">
					<button class="load-more" onclick=@Click( LoadMore )>@(_loading ? "LOADING..." : "LOAD MORE")</button>
				</div>
			}
		}
	</div>

	<div class="footer">
		<button class="back" onclick=@Click( OnBack, SfxType.MenuStart )>BACK</button>
	</div>
</root>

@code
{
	public WorkshopStage Stage { get; set; }

	// Session-static so a run / board round-trip returns to the same view. The sort lives in the
	// persisted settings instead, so it also survives a restart.
	private static string LastSearch = "";

	private string _search = "";
	private WorkshopSortMode _sort = WorkshopSortMode.Newest;
	private bool _loading = true;
	private string _status = "";
	private int _generation;
	private readonly List<Storage.QueryItem> _items = new();
	private Storage.QueryResult _result;

	// The tile currently installing / opening its board (0 = none): tiles ignore clicks while one is
	// busy, so a slow Steam download can't stack runs. _failedItem shows FAILED until the next action.
	private ulong _busyItem;
	private bool _busyIsBoard;
	private ulong _failedItem;

	private EditorTextEntry SearchEntry { get; set; }
	private CharacterPicker CharacterPickerPanel { get; set; }
	private bool _characterPickerOpen;

	/// <summary>The stage suppresses its Back handling and routes Esc here while this is true.</summary>
	public bool ModalOpen => _characterPickerOpen;

	public void CloseModal()
	{
		if ( _characterPickerOpen )
		{
			if ( CharacterPickerPanel is not null ) CharacterPickerPanel.RequestClose();
			else OnCharacterPickerOpenChanged( false );
		}
	}

	private void OnCharacterPickerOpenChanged( bool open )
	{
		_characterPickerOpen = open;
		StateHasChanged();
	}

	// Tile preview defs, cached per file id + the METADATA they were parsed from — a republished item
	// comes back from a fresh query with new metadata and must re-parse, not keep the old layout. The
	// def is the level JSON embedded in metadata (current revision) first, else the installed local
	// copy (covers items published before the embed existed). Null = falls back to the uploaded PNG;
	// also refreshed when an install succeeds. _previewVersion feeds BuildHash so in-place cache
	// overwrites repaint (Count alone wouldn't change).
	private readonly Dictionary<ulong, (string Metadata, LevelDef Def)> _previewDefs = new();
	private int _previewVersion;

	private LevelDef PreviewDef( Storage.QueryItem item )
	{
		if ( _previewDefs.TryGetValue( item.Id, out var cached ) && cached.Metadata == item.Metadata )
			return cached.Def;

		var def = WorkshopLevels.PreviewDefFromMetadata( item.Metadata )
			?? Levels.Get( WorkshopLevels.LevelIdFor( item.Id ) );
		_previewDefs[item.Id] = (item.Metadata, def);
		_previewVersion++;
		return def;
	}

	// A successful install parsed the definitive current revision — upgrade the tile (covers the
	// PNG-fallback case and an installed-copy refresh).
	private void CachePreviewDef( Storage.QueryItem item, LevelDef def )
	{
		_previewDefs[item.Id] = (item.Metadata, def);
		_previewVersion++;
	}

	// The item's Steam Workshop page as a rich-text anchor (material-icon ligature glyph). The
	// engine's Label opens valid http/https anchors in the user's browser on click.
	private static string WebLinkHtml( Storage.QueryItem item )
		=> $"<a href=\"{WorkshopLevels.ItemPageUrl( item.Id )}\">open_in_new</a>";

	// Forced-character badge sizing, matching the level browser's tiles (40px marker on a 150px tile)
	// scaled to this grid's 190px faces.
	private static string ForcedCharacterStyle( CharacterDef character )
	{
		float size = 40f * (190f / 150f) * character.PreviewScale;
		return FormattableString.Invariant( $"width:{size:0.###}px;height:{size:0.###}px;" );
	}

	private string SortLabel => _sort switch
	{
		WorkshopSortMode.Newest => "Newest",
		WorkshopSortMode.Trending => "Trending",
		_ => "Top",
	};

	private static Action Click( Action a, SfxType sfx = SfxType.MenuBlip )
		=> () => { Audio.PlaySfx( sfx, 0.7f ); a?.Invoke(); };

	// ── hover tooltip (same record-then-poll pattern as HighscoreScreen / LevelEditorHud) ───────────
	private PixelTooltip Tooltip { get; set; }
	private Panel _tipPanel;
	private string _tipText;

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

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

	protected override void OnStart()
	{
		base.OnStart();
		_search = LastSearch;
		// Clamped: a hand-edited settings file could hold an out-of-range value, which would break the
		// modulo cycle below.
		_sort = (WorkshopSortMode)Math.Clamp( (int)Settings.Current.WorkshopSort, 0, 2 );
		_ = Refresh();
	}

	private Storage.SortOrder QuerySort => _sort switch
	{
		WorkshopSortMode.Newest => Storage.SortOrder.RankedByPublicationDate,
		WorkshopSortMode.Trending => Storage.SortOrder.RankedByTrend,
		_ => Storage.SortOrder.RankedByVote,
	};

	private async Task Refresh()
	{
		int generation = ++_generation;
		_loading = true;
		_status = "";
		StateHasChanged();

		try
		{
			var result = await WorkshopLevels.BuildQuery( _search, QuerySort ).Run();
			if ( !this.IsValid() || generation != _generation )
				return;

			_result = result;
			_items.Clear();
			if ( result?.Items is not null )
				AddItems( result.Items );
		}
		catch ( Exception e )
		{
			if ( !this.IsValid() || generation != _generation )
				return;

			// No Steam, or the query failed — degrade quietly, like the leaderboard screens.
			Log.Info( $"BlockParty: workshop query unavailable ({e.Message})." );
			_result = null;
			_items.Clear();
			_status = "STEAM UNAVAILABLE";
		}

		_loading = false;
		StateHasChanged();
	}

	// Take a page of query results into the grid, caching each author name so a level's board can
	// credit it (Steam's owner isn't carried by the level JSON — see WorkshopLevels.AuthorFor).
	private void AddItems( IEnumerable<Storage.QueryItem> items )
	{
		foreach ( var item in items.Where( i => i is not null && !i.Banned ) )
		{
			WorkshopLevels.RememberItem( item );
			_items.Add( item );
		}
	}

	// Search-as-you-type with the level-select board fetch's debounce: wait out a pause in typing,
	// then only the newest edit's refresh survives its own generation check.
	private int _searchDebounce;

	private void OnSearchEdited( string value )
	{
		_search = value ?? "";
		LastSearch = _search;
		_ = DebouncedRefresh();
	}

	private async Task DebouncedRefresh()
	{
		int debounce = ++_searchDebounce;
		await Task.Delay( 200 );
		if ( !this.IsValid() || debounce != _searchDebounce )
			return;
		await Refresh();
	}

	// TextEntry.Value can't clear a focused box — write .Text and blur (see LevelBrowserOverlay).
	private void ClearSearchBox()
	{
		_search = "";
		LastSearch = "";
		if ( SearchEntry is not null )
		{
			SearchEntry.Text = "";
			SearchEntry.Blur();
		}
		_ = Refresh();
	}

	private void CycleSort()
	{
		_sort = (WorkshopSortMode)(((int)_sort + 1) % 3);
		Settings.Current.WorkshopSort = _sort;
		Settings.Save();
		_ = Refresh();
	}

	private async void LoadMore()
	{
		if ( _loading || _result?.HasMoreResults() != true )
			return;

		int generation = ++_generation;
		_loading = true;
		StateHasChanged();

		try
		{
			var next = await _result.GetNextResults();
			if ( !this.IsValid() || generation != _generation )
				return;

			_result = next;
			if ( next?.Items is not null )
				AddItems( next.Items );
		}
		catch ( Exception e )
		{
			if ( !this.IsValid() || generation != _generation )
				return;
			Log.Info( $"BlockParty: workshop page fetch failed ({e.Message})." );
		}

		_loading = false;
		StateHasChanged();
	}

	// Install (Steam serves its cache when the item is unchanged) and start the run. The busy flag
	// keeps the whole grid single-flight; a failure marks the tile until the next click.
	private async void PlayItem( Storage.QueryItem item )
	{
		if ( _busyItem != 0 || item is null )
			return;

		// Fast path: the installed copy already matches this item's published content hash — play it
		// immediately, no Steam round-trip (and no Steam needed). A republish changes the hash, so a
		// stale copy still takes the install below and self-heals.
		if ( WorkshopLevels.TryGetInstalledCurrent( item ) is LevelDef current )
		{
			GameManager.Instance?.StartLevel( current.Id );
			return;
		}

		_busyItem = item.Id;
		_busyIsBoard = false;
		_failedItem = 0;
		StateHasChanged();

		var def = await WorkshopLevels.InstallAsync( item );
		if ( !this.IsValid() )
			return;

		_busyItem = 0;
		if ( def is null )
		{
			_failedItem = item.Id;
			StateHasChanged();
			return;
		}

		CachePreviewDef( item, def );
		GameManager.Instance?.StartLevel( def.Id );
	}

	// The board needs the level installed too: its stat name includes the content hash, and the
	// row-consistency prune resolves the level by id.
	private async void OpenBoard( Storage.QueryItem item )
	{
		if ( _busyItem != 0 || item is null )
			return;

		// Same fast path as PlayItem: the board just needs the level registered at the current
		// revision (stat name includes the content hash; the prune resolves the level by id).
		if ( WorkshopLevels.TryGetInstalledCurrent( item ) is LevelDef current )
		{
			Stage?.FadeToStage( new HighscoreStage( Stage.Manager, levelId: current.Id, backToWorkshop: true ) );
			return;
		}

		_busyItem = item.Id;
		_busyIsBoard = true;
		_failedItem = 0;
		StateHasChanged();

		var def = await WorkshopLevels.InstallAsync( item );
		if ( !this.IsValid() )
			return;

		_busyItem = 0;
		if ( def is null )
		{
			_failedItem = item.Id;
			StateHasChanged();
			return;
		}

		CachePreviewDef( item, def );
		Stage?.FadeToStage( new HighscoreStage( Stage.Manager, levelId: def.Id, backToWorkshop: true ) );
	}

	private void OnBack() => Stage?.GoBack();

	protected override int BuildHash() => HashCode.Combine(
		_loading,
		_items.Count,
		_status,
		HashCode.Combine( _search, (int)_sort, _busyItem, _failedItem, _result?.NextCursor ),
		_characterPickerOpen,
		_previewVersion,
		(int)((Stage?.FadeAlpha ?? 0f) * 20f) );
}