Game/WorkshopLevels.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;

namespace BlockParty;

/// <summary>
/// Steam Workshop player levels, built on the engine's Storage/UGC API.
/// <para>
/// PUBLISH (standalone editor only): the open level's JSON goes into a <see cref="Storage.Entry"/>
/// (one per published local level, matched by the <c>levelId</c> meta) and <c>entry.Publish</c> opens
/// the engine's workshop modal — the player confirms title/description/visibility there, so publishing
/// is never silent. Republishing the same level reuses its entry, and the engine's <c>_workshopId</c>
/// meta then updates the existing workshop item in place.
/// </para><para>
/// PLAY: <see cref="InstallAsync"/> downloads an item, keeps a copy at <c>workshop/{fileId}.json</c>
/// in the data folder, and registers it as <c>ws{fileId}</c> — the workshop file id is the global
/// identity; the author's own id inside the JSON is never trusted. <see cref="LoadAll"/> folds the
/// copies back into <see cref="Levels.Reload"/> at boot so boards and replays resolve offline.
/// Leaderboard stats append <see cref="ContentHash"/> (see <see cref="Leaderboard.StatNameForLevel"/>),
/// so an author update starts a fresh board instead of mixing scores across layouts.
/// </para>
/// </summary>
public static class WorkshopLevels
{
	/// <summary>Storage entry type (publisher side). Letters only per the engine's type rule; also the
	/// workshop tag + <c>type</c> keyvalue every published item carries.</summary>
	public const string EntryType = "level";

	/// <summary>Data-folder directory holding installed copies (<c>workshop/{fileId}.json</c>), beside
	/// <see cref="LocalLevels.DirName"/>.</summary>
	public const string DirName = "workshop";

	/// <summary>Registry-id prefix: workshop levels register as <c>ws{fileId}</c>.</summary>
	public const string IdPrefix = "ws";

	/// <summary>Steam's developer-metadata cap (k_cchDeveloperMetadataMax = 5000 bytes), with slack —
	/// the whole <c>_meta.json</c> ships as the item's Metadata, so the embedded level must share it.</summary>
	private const int MAX_ITEM_METADATA = 4900;

	/// <summary>Steam-side item details (author id + display name, description) by registry id,
	/// remembered as browse results arrive. None of it is part of the downloaded level JSON (which must
	/// stay byte-identical for <see cref="ContentHash"/>), and every route to a workshop board goes
	/// through the browser first, so a session-lifetime cache is enough — the board just shows no
	/// author / description if it's ever missed.</summary>
	private static readonly Dictionary<string, (ulong AuthorId, string Author, string Description)> ItemDetails = new();

	/// <summary>s&amp;box's Steam AppID: every game's workshop items share it, so an author's item list
	/// is filtered to it (not to this game — Steam has no per-game filter).</summary>
	private const ulong SBOX_APP_ID = 590830;

	/// <summary>Longest description a board shows before a hard cut (well past what one line fits, so
	/// the CSS ellipsis is what the player actually sees; this just keeps a wall of text out of the label).</summary>
	private const int MAX_BOARD_DESCRIPTION = 200;

	/// <summary>Remember a browsed item's author and description.</summary>
	public static void RememberItem( Storage.QueryItem item )
	{
		if ( item is null ) return;
		string author = string.IsNullOrWhiteSpace( item.Owner?.Name ) ? null : item.Owner.Name.Trim();
		ulong authorId = item.Owner is null ? 0UL : (ulong)item.Owner.Id;
		ItemDetails[LevelIdFor( item.Id )] = (authorId, author, CollapseDescription( item.Description ));
	}

	/// <summary>Author display name for a workshop level id, or null if we haven't seen the item.</summary>
	public static string AuthorFor( string levelId )
		=> levelId is not null && ItemDetails.TryGetValue( levelId, out var d ) ? d.Author : null;

	/// <summary>Author SteamId for a workshop level id, or 0 (unseen item, or Steam gave no owner id).</summary>
	public static long AuthorIdFor( string levelId )
		=> levelId is not null && ItemDetails.TryGetValue( levelId, out var d ) ? (long)d.AuthorId : 0L;

	/// <summary>Web page listing the author's s&amp;box workshop items, or null (unseen item, or Steam
	/// gave no owner id).</summary>
	public static string AuthorWorkshopUrlFor( string levelId )
		=> levelId is not null && ItemDetails.TryGetValue( levelId, out var d ) && d.AuthorId != 0
			? AuthorWorkshopUrl( d.AuthorId )
			: null;

	/// <summary>Web page listing a player's s&amp;box workshop items.</summary>
	public static string AuthorWorkshopUrl( ulong steamId )
		=> $"https://steamcommunity.com/profiles/{steamId}/myworkshopfiles/?appid={SBOX_APP_ID}";

	/// <summary>Single-line description for a workshop level id, or null if blank / unseen.</summary>
	public static string DescriptionFor( string levelId )
		=> levelId is not null && ItemDetails.TryGetValue( levelId, out var d ) ? d.Description : null;

	// Steam descriptions are free multi-line text; the board shows one nowrap line, so fold every
	// whitespace run (newlines included) to a space and hard-cap the length.
	private static string CollapseDescription( string text )
	{
		if ( string.IsNullOrWhiteSpace( text ) ) return null;
		var sb = new System.Text.StringBuilder( text.Length );
		bool space = false;
		foreach ( char c in text.Trim() )
		{
			if ( char.IsWhiteSpace( c ) ) { space = true; continue; }
			if ( space ) { sb.Append( ' ' ); space = false; }
			sb.Append( c );
			if ( sb.Length >= MAX_BOARD_DESCRIPTION ) { sb.Append( "..." ); break; }
		}
		return sb.ToString();
	}

	/// <summary>Registry id for a workshop file id.</summary>
	public static string LevelIdFor( ulong fileId ) => $"{IdPrefix}{fileId}";

	/// <summary>The item's Steam Workshop web page.</summary>
	public static string ItemPageUrl( ulong fileId )
		=> $"https://steamcommunity.com/sharedfiles/filedetails/?id={fileId}";

	/// <summary>The Steam Workshop web page for a workshop level id, or null for any other id.</summary>
	public static string ItemPageUrlFor( string levelId )
		=> IsWorkshopId( levelId ) && ulong.TryParse( levelId.AsSpan( IdPrefix.Length ), out var fileId )
			? ItemPageUrl( fileId )
			: null;

	/// <summary>Whether an id is in the reserved workshop namespace: "ws" + digits, nothing else.</summary>
	public static bool IsWorkshopId( string id )
	{
		if ( string.IsNullOrEmpty( id ) || id.Length <= IdPrefix.Length ) return false;
		if ( !id.StartsWith( IdPrefix, StringComparison.Ordinal ) ) return false;
		for ( int i = IdPrefix.Length; i < id.Length; i++ )
			if ( id[i] < '0' || id[i] > '9' ) return false;
		return true;
	}

	/// <summary>FNV-1a 32 over the level JSON's UTF-8 bytes, as 8 lowercase hex chars. Publisher and
	/// installer hash the same byte-identical text, so the stat suffix agrees on every client.</summary>
	public static string ContentHash( string json )
	{
		uint h = 2166136261u;
		foreach ( byte b in System.Text.Encoding.UTF8.GetBytes( json ?? "" ) )
			h = (h ^ b) * 16777619u;
		return h.ToString( "x8", System.Globalization.CultureInfo.InvariantCulture );
	}

	// ---- publisher side ----

	/// <summary>The storage entry already holding this local level's published files, or a fresh one.
	/// The mapping lives on the entries themselves (meta <c>levelId</c>) — no separate index to drift.</summary>
	public static Storage.Entry FindOrCreateEntry( string levelId )
	{
		var existing = Storage.GetAll( EntryType )
			.FirstOrDefault( e => e.GetMeta<string>( "levelId" ) == levelId );
		return existing ?? Storage.CreateEntry( EntryType );
	}

	/// <summary>
	/// Publish the editor's open level: save it first (published content must match the saved level),
	/// stage its JSON + meta + thumbnail into its storage entry, and hand off to the engine's workshop
	/// modal. <paramref name="onComplete"/> fires with the workshop file id if the player goes through
	/// with it; refusals report through <see cref="LevelEditorStage.ReportProjectError"/>.
	/// <para>
	/// Works from the DEV editor too (for testing the pipeline) — but note items are stamped with the
	/// running <c>Game.Ident</c> at publish, and <see cref="BuildQuery"/> filters on it, so editor
	/// (<c>local.*</c>) and published-game items live in separate, mutually invisible pools.
	/// </para>
	/// </summary>
	public static void Publish( LevelEditorStage stage, Action<ulong> onComplete )
	{
		// Save first: same refusal ladder as a plain save (id validity, shipped/workshop-id collisions,
		// overwrite protection), and the published JSON matches the saved level.
		stage.SaveToProject();
		if ( !string.IsNullOrEmpty( stage.LastError ) )
			return;

		var id = stage.Level.Id?.Trim();
		var json = stage.Level.ToJsonString();
		var def = stage.Level.ToLevelDef();

		Storage.Entry entry;
		try
		{
			entry = FindOrCreateEntry( id );
			entry.Files.WriteAllText( "level.json", json );
			entry.SetMeta( "levelId", id );
			entry.SetMeta( "name", string.IsNullOrWhiteSpace( def.Name ) ? id : def.Name );
			entry.SetMeta( "blocks", def.Count );

			// Embed the level itself when the whole _meta.json (which becomes the item's queryable
			// Metadata verbatim) stays under Steam's 5000-byte developer-metadata cap: the browser can
			// then draw the SAME live animated preview as the local pickers without downloading.
			// Oversized levels drop the key (a republish may shrink OR grow past the cap) and their
			// tiles fall back to the uploaded thumbnail image. The final SetMeta below persists the
			// removal — a null SetMeta alone edits the dict without rewriting the file.
			entry.SetMeta( "level", json );
			bool fits = System.Text.Encoding.UTF8.GetByteCount( entry.Files.ReadAllText( "_meta.json" ) ) <= MAX_ITEM_METADATA;
			if ( !fits )
				entry.SetMeta<string>( "level", null );
			entry.SetMeta( "hash", ContentHash( json ) );
		}
		catch ( Exception ex )
		{
			stage.ReportProjectError( $"couldn't stage workshop files: {ex.Message}" );
			return;
		}

		// A thumbnail failure shouldn't block publishing — the modal just shows no preview.
		try { entry.SetThumbnail( WorkshopThumbnail.Render( def ) ); }
		catch ( Exception ex ) { Log.Warning( $"[BlockParty] workshop thumbnail failed: {ex.Message}" ); }

		entry.Publish( new Sandbox.Modals.WorkshopPublishOptions
		{
			Title = string.IsNullOrWhiteSpace( def.Name ) ? id : def.Name,
			// OUR game discriminator: every game's UGC shares the s&box AppID and the engine stamps
			// only type/source keyvalues (no package ident), so without this a query couldn't tell a
			// blockparty level from another game's "level"-typed content. BuildQuery requires it back.
			// "schema" makes the level's wire version query-filterable: BuildQuery requires the exact
			// version this build reads, so content a client can't parse (older unversioned items, or
			// future-schema items seen by an old client) is INVISIBLE in its browser rather than a
			// FAILED tile.
			KeyValues = new() { ["game"] = Game.Ident, ["schema"] = EditorLevel.SCHEMA_VERSION.ToString() },
			// The engine modal invokes OnComplete UNCONDITIONALLY after the submit — a failed Steam
			// upload (network drop, quota) reports item id 0. Only a real id is success; 0 surfaces as
			// an error instead of "PUBLISHED #0". (A cancelled modal never calls back at all.)
			OnComplete = fileId =>
			{
				if ( fileId != 0 )
				{
					Achievements.AwardWorkshopPublished();
					onComplete?.Invoke( fileId );
					return;
				}
				stage.ReportProjectError( "Steam publish failed — nothing was uploaded; try again" );
				Log.Warning( "[BlockParty] workshop publish failed (engine modal reported item id 0)." );
			},
		} );
	}

	// ---- consumer side ----

	/// <summary>A browse query for this game's published levels. The engine stamps the entry-type tag
	/// and a <c>type</c> keyvalue at publish; the <c>game</c> keyvalue is ours (see
	/// <see cref="Publish"/>) and is what actually keeps other games' UGC (everything shares the
	/// s&amp;box workshop AppID) out of the results. NOTE: it also splits dev-editor
	/// (<c>local.*</c>) items from published-game items, since it holds the publishing ident.</summary>
	public static Storage.Query BuildQuery( string searchText, Storage.SortOrder sort )
	{
		var query = new Storage.Query
		{
			TagsRequired = { EntryType },
			// game/type keep other games' UGC out; schema keeps content THIS build can't parse out
			// (see Publish) — each client browses only levels written in the wire version it reads.
			KeyValues = { ["game"] = Game.Ident, ["type"] = EntryType, ["schema"] = EditorLevel.SCHEMA_VERSION.ToString() },
			SortOrder = sort,
			RankTrendDays = 7,
		};
		if ( !string.IsNullOrWhiteSpace( searchText ) )
			query.SearchText = searchText.Trim();
		return query;
	}

	/// <summary>
	/// Download a workshop level, persist the local copy, and register it as <c>ws{fileId}</c> in the
	/// live registry. Returns the registered def, or null (with a log) on any failure. Always re-runs
	/// for an already-installed level — Steam serves its cache when fresh, and this self-heals the
	/// local copy/hash after an author republish.
	/// </summary>
	public static async Task<LevelDef> InstallAsync( Storage.QueryItem item, CancellationToken token = default )
	{
		if ( item is null ) return null;

		Storage.Entry entry;
		try { entry = await item.Install( token ); }
		catch ( Exception ex )
		{
			Log.Warning( $"[BlockParty] workshop install {item.Id} failed: {ex.Message}" );
			return null;
		}

		if ( entry is null || !entry.Files.FileExists( "level.json" ) )
		{
			Log.Warning( $"[BlockParty] workshop item {item.Id} has no level.json." );
			return null;
		}

		var json = entry.Files.ReadAllText( "level.json" );
		var def = BuildDef( item.Id, json, out var error );
		if ( def is null )
		{
			Log.Warning( $"[BlockParty] workshop item {item.Id}: {error}" );
			return null;
		}

		try
		{
			FileSystem.Data.CreateDirectory( DirName );
			FileSystem.Data.WriteAllText( $"{DirName}/{item.Id}.json", json );
		}
		catch ( Exception ex )
		{
			// Playable this session either way; only boot-time re-registration is lost.
			Log.Warning( $"[BlockParty] couldn't persist workshop level {item.Id}: {ex.Message}" );
		}

		Levels.RegisterWorkshop( def );
		return def;
	}

	/// <summary>Read every installed workshop copy for the registry fold-in (see
	/// <see cref="Levels.Reload"/>). Identity comes from the FILENAME; unparseable files skip quietly,
	/// mirroring <see cref="LocalLevels.LoadAll"/>.</summary>
	public static List<LevelDef> LoadAll()
	{
		var result = new List<LevelDef>();

		try
		{
			foreach ( var name in FileSystem.Data.FindFile( DirName, "*.json" ) )
			{
				var stem = name.EndsWith( ".json", StringComparison.OrdinalIgnoreCase ) ? name[..^5] : name;
				if ( !ulong.TryParse( stem, out ulong fileId ) ) continue;

				try
				{
					var def = BuildDef( fileId, FileSystem.Data.ReadAllText( $"{DirName}/{name}" ), out _ );
					if ( def is not null ) result.Add( def );
				}
				catch { /* not a level file — skip quietly */ }
			}
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[BlockParty] scanning workshop levels: {ex.Message}" );
		}

		return result;
	}

	// The slice of an item's Metadata (the raw _meta.json: StorageMeta with a string-dict) we read back.
	private sealed class ItemMeta
	{
		public System.Collections.Generic.Dictionary<string, string> Meta { get; set; }
	}

	/// <summary>Rebuild a browse tile's level from the item's METADATA (no download): the level JSON
	/// embedded at publish, when it fit the metadata cap. Null for oversized levels, pre-embed items
	/// and anything unparseable — the tile falls back to the uploaded thumbnail image.</summary>
	public static LevelDef PreviewDefFromMetadata( string metadata )
	{
		if ( string.IsNullOrWhiteSpace( metadata ) ) return null;
		try
		{
			var meta = Json.Deserialize<ItemMeta>( metadata )?.Meta;
			if ( meta is null || !meta.TryGetValue( "level", out var encoded ) ) return null;

			// SetMeta stores each value JSON-encoded, so the level JSON arrives as a string literal.
			var json = Json.Deserialize<string>( encoded );
			return EditorLevel.FromJsonString( json, out _ )?.ToLevelDef();
		}
		catch { return null; }
	}

	/// <summary>The publish-time content hash carried in an item's metadata, or null (unparseable
	/// metadata — every published level carries the <c>hash</c> meta).</summary>
	public static string HashFromMetadata( string metadata )
	{
		if ( string.IsNullOrWhiteSpace( metadata ) ) return null;
		try
		{
			var meta = Json.Deserialize<ItemMeta>( metadata )?.Meta;
			return meta is not null && meta.TryGetValue( "hash", out var encoded )
				? Json.Deserialize<string>( encoded )
				: null;
		}
		catch { return null; }
	}

	/// <summary>The installed, registered def for this item when it's already CURRENT — its content
	/// hash matches the item's publish-time metadata hash (two in-memory strings). The play/board
	/// fast path: a hit skips the whole Steam install round-trip, and works offline. Null = not
	/// installed, stale (author republished), or no hash to compare — install instead.</summary>
	public static LevelDef TryGetInstalledCurrent( Storage.QueryItem item )
	{
		string hash = HashFromMetadata( item?.Metadata );
		if ( hash is null ) return null;

		var def = Levels.Get( LevelIdFor( item.Id ) );
		return def is { IsWorkshop: true } && def.WorkshopContentHash == hash ? def : null;
	}

	/// <summary>Dev diagnostic: with no argument, dump what the game's browse filter matches AND what
	/// a fully unfiltered newest-first query sees (so a filter mismatch is obvious); with a workshop
	/// file id, dump that item's actual tags/keyvalues/metadata as Steam returns them.</summary>
	[ConCmd( "workshop_debug" )]
	public static void DebugCmd( string fileId = "" )
	{
		if ( !Game.IsEditor ) return;
		_ = DebugAsync( fileId );
	}

	private static async Task DebugAsync( string fileIdStr )
	{
		try
		{
			if ( ulong.TryParse( fileIdStr, out ulong fileId ) )
			{
				var details = await new Storage.Query { FileIds = new() { fileId } }.Run();
				if ( details?.Items is not { Count: > 0 } items )
				{
					Log.Info( $"[BlockParty] workshop_debug: no details for {fileId}." );
					return;
				}
				foreach ( var i in items )
					Log.Info( $"[BlockParty] item {i.Id} '{i.Title}' vis={i.Visibility} banned={i.Banned} " +
						$"owner={i.Owner?.Name} tags=[{string.Join( ",", i.Tags ?? new() )}] " +
						$"kv={{{string.Join( ",", (i.KeyValues ?? new()).Select( kv => $"{kv.Key}={kv.Value}" ) )}}} " +
						$"meta={i.Metadata}" );
				return;
			}

			var filtered = await BuildQuery( null, Storage.SortOrder.RankedByPublicationDate ).Run();
			Log.Info( $"[BlockParty] workshop_debug: game filter (tag '{EntryType}' + game={Game.Ident} + type={EntryType}) → {filtered?.ResultCount ?? 0} of {filtered?.TotalCount ?? 0}." );

			var all = await new Storage.Query { SortOrder = Storage.SortOrder.RankedByPublicationDate }.Run();
			Log.Info( $"[BlockParty] workshop_debug: UNFILTERED newest → {all?.ResultCount ?? 0} of {all?.TotalCount ?? 0}; first items:" );
			foreach ( var i in (all?.Items ?? new()).Take( 10 ) )
				Log.Info( $"[BlockParty]   {i.Id} '{i.Title}' tags=[{string.Join( ",", i.Tags ?? new() )}] " +
					$"kv={{{string.Join( ",", (i.KeyValues ?? new()).Select( kv => $"{kv.Key}={kv.Value}" ) )}}}" );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[BlockParty] workshop_debug failed: {ex.Message}" );
		}
	}

	// Parse + brand a downloaded level. The workshop file id is the identity — the author's own id in
	// the JSON is overwritten, so it can never collide with shipped/local content or another author.
	private static LevelDef BuildDef( ulong fileId, string json, out string error )
	{
		var e = EditorLevel.FromJsonString( json, out error );
		if ( e is null ) return null;

		var def = e.ToLevelDef();
		def.Id = LevelIdFor( fileId );
		def.IsWorkshop = true;
		def.WorkshopContentHash = ContentHash( json );
		def.SourcePath = $"{DirName}/{fileId}.json";
		return def;
	}
}