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

namespace BlockParty;

public sealed class DailyChallengeAttempt
{
	public string DailyId { get; set; }
	public DateTimeOffset StartedAt { get; set; }
	public DateTimeOffset? CompletedAt { get; set; }
	/// <summary>True once ANY run on this day was a full victory. Distinct from
	/// <see cref="CompletedAt"/>, which only means a run finished (a death completes an attempt too).
	/// Latches on the first win and is never cleared, so a day counts once toward the daily
	/// achievements no matter how many of its attempts are won.</summary>
	public bool Won { get; set; }
	/// <summary>True once this day has been counted toward the "played" achievements. Latches on the
	/// day's FIRST real attempt, so extra attempts on the same day never re-count it. Also the marker
	/// <see cref="DailyChallengeProgress.PlayedStreakEndingAt"/> reads to walk consecutive days.</summary>
	public bool PlayCounted { get; set; }
	/// <summary>How many runs were launched on this day (a day's limit comes from its generated
	/// <see cref="DailyDef.MaxAttempts"/>). Absorbs the backend count on each launch, so it can exceed
	/// the runs started on this machine.</summary>
	public int AttemptsUsed { get; set; }
	/// <summary>Best completed run's score (the stored <see cref="Run"/> is the best run, not the last).</summary>
	public int FinalScore { get; set; }
	public RunData Run { get; set; }
}

/// <summary>
/// Per-player daily attempt counter and best-replay store. Attempt limits are enforced by TWO
/// counters merged via <see cref="AttemptsUsed"/>: the local file (offline / fast path) and a
/// per-day backend stat (<see cref="DailyChallenge.AttemptsStatName"/>, incremented on every run
/// launch and fetched by the daily hub), so deleting the local file does not re-open a limited day
/// once the backend has the count. The backend fetch is best-effort — offline or cold-start
/// sessions fall back to the local file alone.
/// <para>
/// For a 1-attempt day the daily leaderboard adds a third, fully authoritative lock:
/// <c>DailyChallengeScreen</c> checks whether the local player's SteamId already has a board entry
/// (see <c>LocalPlayerOnBoard</c>).
/// </para>
/// </summary>
public static class DailyChallengeProgress
{
	public static string FilePath => $"blockparty_daily_challenges_v{Leaderboard.LEADERBOARD_VERSION}.json";

	private static List<DailyChallengeAttempt> _attempts;
	private static int _loadedVersion = -1;

	public static IReadOnlyList<DailyChallengeAttempt> All
	{
		get
		{
			EnsureLoaded();
			return _attempts;
		}
	}

	public static DailyChallengeAttempt Get( string dailyId )
	{
		if ( string.IsNullOrEmpty( dailyId ) ) return null;
		EnsureLoaded();
		return _attempts.FirstOrDefault( a => a.DailyId == dailyId );
	}

	public static bool IsAttempted( string dailyId ) => Get( dailyId ) is not null;

	// Backend attempt counts last fetched this session (see RefreshBackendAttempts), merged with the
	// local file so the stricter of the two gates.
	private static readonly Dictionary<string, int> _backendAttempts = new();

	public static int AttemptsUsed( string dailyId )
		=> Math.Max( Get( dailyId )?.AttemptsUsed ?? 0, _backendAttempts.GetValueOrDefault( dailyId ?? "" ) );

	/// <summary>Whether another run may be launched for the day. <paramref name="maxAttempts"/> is the
	/// day's generated limit (null = unlimited).</summary>
	public static bool HasAttemptsRemaining( string dailyId, int? maxAttempts )
		=> maxAttempts is not int max || AttemptsUsed( dailyId ) < max;

	/// <summary>Count one launched run against the day, locally and on the backend stat. Called ONLY
	/// from <see cref="GameManager"/>'s daily start/restart paths so an attempt is counted exactly
	/// once per run.</summary>
	public static void RegisterAttemptStart( string dailyId )
	{
		if ( string.IsNullOrEmpty( dailyId ) )
			return;

		// Count from the MERGED total, not the local record: on a new machine the file is 0 while the
		// backend already holds the day's spent attempts, and 0→1 locally leaves the merged gate parked
		// at the backend value until the file catches up — re-issuing the whole limit. Writing the merged
		// total back also lets the file remember the backend count for later offline sessions.
		var attempt = GetOrCreate( dailyId );
		int used = AttemptsUsed( dailyId ) + 1;
		attempt.AttemptsUsed = used;

		// A win isn't needed to have "played" a day — launching a real run is enough (it has already
		// burned an attempt against the day's limit). Latched so the day's later attempts don't
		// re-count it, and persisted before the submit so a crash in between can't either.
		bool firstPlayToday = !attempt.PlayCounted;
		attempt.PlayCounted = true;
		Save();

		if ( firstPlayToday )
			Achievements.SubmitDailyPlayed( PlayedStreakEndingAt( dailyId ) );

		// Mirror the increment into the session's backend view immediately — the real fetch may lag
		// behind the backend's ingestion, and the local file could be deleted mid-session. `used`
		// already exceeds the cached backend value, so this is a plain +1 on the merged view.
		_backendAttempts[dailyId] = used;
		Sandbox.Services.Stats.Increment( DailyChallenge.AttemptsStatName( dailyId ), 1 );
		Sandbox.Services.Stats.Flush();
	}

	/// <summary>How many consecutive days of daily challenges were played up to and INCLUDING
	/// <paramref name="dailyId"/> — 1 on a lone day, 0 if that day itself wasn't played. Walks backwards
	/// a UTC day at a time (daily ids are <c>yyyyMMdd</c>) until it finds a gap, so it terminates at the
	/// first unplayed day rather than scanning the whole history. Call it AFTER latching the day's
	/// <see cref="DailyChallengeAttempt.PlayCounted"/>, or the day in question won't count itself.
	/// <para>Reads the local attempt file only, so a <see cref="Leaderboard.LEADERBOARD_VERSION"/> bump
	/// (which retires that file) restarts streaks from 1. The backend stat keeps the best streak already
	/// reached, so a wipe can't take earned progress away.</para></summary>
	public static int PlayedStreakEndingAt( string dailyId )
	{
		if ( !DailyChallenge.TryParseId( dailyId, out var date ) )
			return 0;

		EnsureLoaded();
		var played = _attempts
			.Where( a => a.PlayCounted && !string.IsNullOrEmpty( a.DailyId ) )
			.Select( a => a.DailyId )
			.ToHashSet();

		int streak = 0;
		while ( played.Contains( DailyChallenge.IdFromDate( date ) ) )
		{
			streak++;
			date = date.AddDays( -1 );
		}
		return streak;
	}

	/// <summary>Fetch the backend attempt count for a day into the session cache (best-effort; the
	/// daily hub awaits this before enabling PLAY on a limited day). Offline or failed fetches leave
	/// the local file as the only gate for the session.</summary>
	public static async System.Threading.Tasks.Task RefreshBackendAttempts( string dailyId )
	{
		if ( string.IsNullOrEmpty( dailyId ) )
			return;

		try
		{
			var stats = Sandbox.Services.Stats.LocalPlayer;
			await stats.Refresh();
			// The attempt counter is Increment-based, and increments accumulate into a stat's SUM —
			// Value is untouched by them (the engine's own Predict path adds to Sum only), so reading
			// Value here would load 0 after a restart and re-open a limited day.
			int value = (int)stats.Get( DailyChallenge.AttemptsStatName( dailyId ) ).Sum;
			if ( value > 0 )
				_backendAttempts[dailyId] = Math.Max( _backendAttempts.GetValueOrDefault( dailyId ), value );
		}
		catch ( Exception e )
		{
			Log.Info( $"BlockParty: daily attempts stat fetch failed for {dailyId} ({e.Message}); the local file gates this session." );
		}
	}

	/// <summary>Record a finished run, keeping the BEST run's score/replay across the day's attempts
	/// (higher score wins; equal score prefers the faster run, mirroring the board tiebreaker). A first
	/// VICTORY on the day also latches <see cref="DailyChallengeAttempt.Won"/> and banks the day toward
	/// the daily achievements — the latch is what keeps a second winning attempt from counting twice.</summary>
	public static void MarkCompleted( string dailyId, RunData run )
	{
		if ( string.IsNullOrEmpty( dailyId ) || run is null )
			return;

		var attempt = GetOrCreate( dailyId );
		attempt.CompletedAt = DateTimeOffset.Now;
		if ( attempt.Run is null || run.FinalScore > attempt.FinalScore
			|| (run.FinalScore == attempt.FinalScore && run.TimeSeconds < attempt.Run.TimeSeconds) )
		{
			attempt.FinalScore = run.FinalScore;
			attempt.Run = run;
		}

		bool firstWin = run.Victory && !attempt.Won;
		if ( firstWin )
			attempt.Won = true;

		Save(); // persist the latch BEFORE submitting, so a crash can't re-count the day
		if ( firstWin )
		{
			Achievements.SubmitDailyBeaten();
			// Won without spending a second attempt. Read through the MERGED counter rather than the
			// record's own field, so a deleted local file can't pass a later attempt off as a first try.
			if ( AttemptsUsed( dailyId ) == 1 )
				Achievements.AwardDailyWonFirstTry();
		}
	}

	private static DailyChallengeAttempt GetOrCreate( string dailyId )
	{
		var existing = Get( dailyId );
		if ( existing is not null )
			return existing;

		var attempt = new DailyChallengeAttempt
		{
			DailyId = dailyId,
			StartedAt = DateTimeOffset.Now,
		};

		_attempts.Insert( 0, attempt );
		return attempt;
	}

	public static RunData GetReplay( string dailyId ) => Get( dailyId )?.Run;

	// --- DEBUG attempt counter (daily_debug_nav) ---------------------------------------------------
	// Per-day counter of DEBUG daily runs (daily_play / DEBUG PLAY / their restarts), kept in its own
	// clearly-named local file so the attempts UI can be exercised without touching real progress.
	// DISPLAY-ONLY: debug runs are never limited, and real gating never reads these.

	public const string DebugAttemptsFilePath = "blockparty_daily_DEBUG_attempts.json";

	private static Dictionary<string, int> _debugAttempts;

	private static Dictionary<string, int> DebugAttempts =>
		_debugAttempts ??= FileSystem.Data.ReadJsonOrDefault<Dictionary<string, int>>( DebugAttemptsFilePath, new() ) ?? new();

	/// <summary>Count one launched DEBUG run against the day's debug counter (see
	/// <see cref="GameManager.StartDailyChallengeDebug"/> / the debug restart path).</summary>
	public static void RegisterDebugAttemptStart( string dailyId )
	{
		if ( string.IsNullOrEmpty( dailyId ) )
			return;

		DebugAttempts[dailyId] = DebugAttempts.GetValueOrDefault( dailyId ) + 1;
		FileSystem.Data.WriteJson( DebugAttemptsFilePath, DebugAttempts );
	}

	/// <summary>Attempt count for UI DISPLAY: the real merged counter normally, or the debug-file
	/// counter while daily_debug_nav is on (so debug runs exercise the attempts UI). Gating always
	/// uses <see cref="AttemptsUsed"/> — this never limits anything.</summary>
	public static int DisplayedAttemptsUsed( string dailyId )
		=> DailyChallengeStage.DebugNavigation
			? DebugAttempts.GetValueOrDefault( dailyId ?? "" )
			: AttemptsUsed( dailyId );

	private static void EnsureLoaded()
	{
		// Static state survives hot reload, so discard the old version's cached attempts on a bump.
		if ( _attempts is null || _loadedVersion != Leaderboard.LEADERBOARD_VERSION )
			Load();
	}

	public static void Load()
	{
		_attempts = FileSystem.Data.ReadJsonOrDefault<List<DailyChallengeAttempt>>( FilePath, new() ) ?? new();
		_loadedVersion = Leaderboard.LEADERBOARD_VERSION;
		_attempts = _attempts
			.Where( a => !string.IsNullOrEmpty( a?.DailyId ) )
			.GroupBy( a => a.DailyId )
			.Select( g => g.OrderByDescending( a => a.StartedAt ).First() )
			.OrderByDescending( a => a.DailyId )
			.ToList();
	}

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