Game/LocalReplays.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace BlockParty;

/// <summary>
/// One archived run: the timestamp it was recorded plus the deterministic replay payload. Stored
/// in the same per-user data folder as the settings (see <see cref="LocalReplays"/>).
/// </summary>
public sealed class LocalReplayEntry
{
	/// <summary>When this replay was saved to the local archive (local clock); the list shows this
	/// and sorts by it. Always the save moment — a downloaded replay's leaderboard submission date
	/// is display-only metadata and never stored here.</summary>
	public DateTimeOffset RecordedAt { get; set; }

	/// <summary>SteamId of the player who recorded it, for the name + avatar shown on the row.</summary>
	public long SteamId { get; set; }

	/// <summary>Display name captured when this replay was saved; falls back to Steam lookup if empty.</summary>
	public string DisplayName { get; set; }

	/// <summary>Optional country code captured from leaderboard metadata for downloaded replays.</summary>
	public string CountryCode { get; set; }

	/// <summary>True when the player has hearted this local replay.</summary>
	public bool IsFavorite { get; set; }

	/// <summary>The run itself — duration, score, victory flag, and the recorded input for replay.</summary>
	public RunData Run { get; set; }
}

/// <summary>
/// Local archive of saved runs so they can be rewatched offline (the My Replays screen), independent
/// of the online leaderboard. Persisted as JSON via <see cref="FileSystem.Data"/>, alongside
/// <see cref="GameSettings"/>. Capped to the most recent N non-favorites so the file can't grow forever
/// (favorites are exempt).
/// </summary>
public static class LocalReplays
{
	public const string FilePath = "blockparty_replays.json";

	/// <summary>Cap on stored NON-favorite runs; the oldest are dropped past it. Favorites sit outside
	/// the cap entirely (never counted, never trimmed) because a heart is an explicit keep request.
	/// Sized from measured entries (~1KB typical, ~11KB with an embedded test-play level): ~11MB
	/// worst case for the capped part, and the file is rewritten whole on every Save, so it should
	/// stay single-digit MB.</summary>
	private const int MaxNonFavoriteEntries = 1000;

	private static List<LocalReplayEntry> _entries;
	private static int _version;
	private static bool _favoritesOnlyFilter;

	/// <summary>Changes whenever the archive mutates, so UI hashes can cheaply repaint.</summary>
	public static int Version => _version;

	/// <summary>Session-only Local Replays filter state. Not written to disk, so it resets on launch.</summary>
	public static bool FavoritesOnlyFilter
	{
		get => _favoritesOnlyFilter;
		set => _favoritesOnlyFilter = value;
	}

	/// <summary>All archived runs, most recent first. Loaded lazily on first access.</summary>
	public static IReadOnlyList<LocalReplayEntry> All
	{
		get
		{
			if ( _entries is null ) Load();
			return _entries;
		}
	}

	/// <summary>Archive a finished run, newest first, trimming the oldest non-favorites past the cap.</summary>
	public static void Add( RunData run )
	{
		Add( run, (long)Game.SteamId, new Friend( Game.SteamId ).Name );
	}

	/// <summary>Archive a replay if it is not already present. Duplicate saves are silent no-ops,
	/// except that a duplicate carrying a level snapshot the archived copy lacks donates it.</summary>
	public static LocalReplayEntry Add( RunData run, long steamId, string displayName = null, string countryCode = null )
	{
		if ( run is null )
			return null;

		if ( _entries is null ) Load();

		if ( steamId == 0 )
			steamId = (long)Game.SteamId;

		// Dedup is keyed on the submitter too, so two players' identical-input runs (e.g. both idle
		// to death on the same daily) don't collapse into a single archive entry.
		var existing = Find( run, steamId );
		if ( existing is not null )
		{
			// A workshop run is archived without a snapshot and greys out once the author republishes
			// (CanReplay's content-hash gate). Its share code DOES carry the recorded level, so
			// re-importing that code is how the run gets revived: merge the snapshot in instead of
			// discarding it. Same revision is guaranteed — SameReplay matches LevelHash. Keyed on
			// decodability, not null-ness, so a stored snapshot that no longer decodes is replaced too.
			if ( existing.Run.LevelData is null && run.LevelData is not null )
			{
				existing.Run.LevelSnapshot = run.LevelSnapshot;
				Save();
			}
			return existing;
		}

		var entry = new LocalReplayEntry
		{
			RecordedAt = DateTimeOffset.Now,
			SteamId = steamId,
			DisplayName = string.IsNullOrWhiteSpace( displayName ) ? null : displayName,
			CountryCode = string.IsNullOrWhiteSpace( countryCode ) ? null : countryCode,
			Run = run
		};
		_entries.Insert( 0, entry );
		TrimOldNonFavorites();

		Save();

		// Editor-only trace: a transient save carries its level layout inline (an editor test-play run,
		// whose level isn't in the persistent registry); a normal save resolves its level by id.
		if ( Game.IsEditor )
			Log.Info( $"BlockParty: saved local replay ({(run.LevelData is not null ? "transient" : "non-transient")}, level '{run.LevelId}', score {run.FinalScore})." );

		return entry;
	}

	/// <summary>Import a replay code into the local archive. Returns false when the string is not a
	/// replayable payload for the current simulation version.</summary>
	public static bool TryImportCode( string code )
	{
		if ( !ReplayCodeCodec.TryDecode( code, out var run, out long steamId ) )
		{
			Log.Info( $"BlockParty replay code: import failed (invalid or incompatible code, {(code ?? "").Length} chars)." );
			return false;
		}

		// Attributed to the player who did the run (carried in the code), not the importer. Name and
		// avatar resolve from the id via Steam wherever the entry is shown.
		Add( run, steamId );
		Achievements.AwardReplayCodeUsed(); // either share-code surface counts — see the award's doc
		Log.Info( $"BlockParty replay code: imported into local replays ({ReplayCodeCodec.Describe( run )}, steamid {steamId})." );
		return true;
	}

	/// <summary>Find an archived entry matching this deterministic replay payload for the given
	/// submitter. A run's identity includes who recorded it, so the same seed+input from two
	/// different players is two distinct entries. <paramref name="steamId"/> of 0 means the local player.</summary>
	public static LocalReplayEntry Find( RunData run, long steamId = 0 )
	{
		if ( run is null )
			return null;

		if ( _entries is null ) Load();

		if ( steamId == 0 )
			steamId = (long)Game.SteamId;

		return _entries.FirstOrDefault( e => e.SteamId == steamId && SameReplay( e.Run, run ) );
	}

	/// <summary>True if this replay payload already exists in the local archive for the given submitter.</summary>
	public static bool Contains( RunData run, long steamId = 0 ) => Find( run, steamId ) is not null;

	/// <summary>Toggle an archived replay's heart state and persist it.</summary>
	public static void ToggleFavorite( LocalReplayEntry entry )
	{
		if ( entry is null )
			return;

		if ( _entries is null ) Load();

		var found = _entries.Contains( entry ) ? entry : Find( entry.Run, entry.SteamId );
		if ( found is null )
			return;

		found.IsFavorite = !found.IsFavorite;
		Save();
	}

	/// <summary>Delete a single archived run (the trash button on the My Replays row) and persist.</summary>
	public static void Remove( LocalReplayEntry entry )
	{
		if ( entry is null )
			return;

		if ( _entries is null ) Load();

		if ( _entries.Remove( entry ) )
			Save();
	}

	/// <summary>Drop every run recorded before <see cref="Sim.MIN_REPLAY_VERSION"/> (favourites
	/// included — see replay_purge_stale). Returns how many went; saves only if any did.</summary>
	public static int PurgeBelowMinVersion( out int favourites )
	{
		if ( _entries is null ) Load();

		favourites = _entries.Count( e => e.IsFavorite && e.Run.SimVersion < Sim.MIN_REPLAY_VERSION );
		int removed = _entries.RemoveAll( e => e.Run.SimVersion < Sim.MIN_REPLAY_VERSION );
		if ( removed > 0 )
			Save();
		return removed;
	}

	public static void Load()
	{
		_entries = FileSystem.Data.ReadJsonOrDefault<List<LocalReplayEntry>>( FilePath, new() ) ?? new();
		// Defensive: keep the list ordered newest-first even if an older file wasn't.
		// Stale-SimVersion runs are deliberately KEPT (they just fail CanReplay and hide from the
		// list): a post-release sim fix ships a version gate that plays them back through the old
		// code path, so deleting them here would destroy exactly the runs that gate revives.
		// The non-favorite trim in Add still bounds the file.
		_entries = _entries.Where( e => e?.Run is not null ).OrderByDescending( e => e.RecordedAt ).ToList();
	}

	public static void Save()
	{
		if ( _entries is null ) return;
		_version++;
		FileSystem.Data.WriteJson( FilePath, _entries );
	}

	/// <summary>Drop the oldest non-favorites until at most <see cref="MaxNonFavoriteEntries"/> remain.
	/// Counting only non-favorites means a full heart list can't push the cap onto the run just
	/// inserted at index 0: it is only ever trimmed when older non-favorites are exhausted, which
	/// the count rules out.</summary>
	private static void TrimOldNonFavorites()
	{
		int nonFavorites = _entries.Count( e => !e.IsFavorite );
		for ( int i = _entries.Count - 1; i >= 0 && nonFavorites > MaxNonFavoriteEntries; i-- )
		{
			if ( _entries[i].IsFavorite )
				continue;

			_entries.RemoveAt( i );
			nonFavorites--;
		}
	}

	private static bool SameReplay( RunData a, RunData b )
	{
		if ( a is null || b is null )
			return false;

		// LevelHash is part of the key: the same seed+input on two revisions of one level id (a
		// republished workshop item) is two distinct runs, and Add's snapshot merge relies on a match
		// meaning the same revision.
		return a.SimVersion == b.SimVersion
			&& a.Seed == b.Seed
			&& a.StepCount == b.StepCount
			&& a.FinalScore == b.FinalScore
			&& string.Equals( a.InputDeltas, b.InputDeltas, StringComparison.Ordinal )
			&& string.Equals( a.DailyId ?? "", b.DailyId ?? "", StringComparison.Ordinal )
			&& string.Equals( a.LevelId ?? "", b.LevelId ?? "", StringComparison.Ordinal )
			&& string.Equals( a.LevelHash ?? "", b.LevelHash ?? "", StringComparison.Ordinal )
			&& string.Equals( a.CharacterId ?? "", b.CharacterId ?? "", StringComparison.Ordinal );
	}
}