Game/DailyMusic.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Sandbox;

namespace BlockParty;

/// <summary>
/// Picks each daily challenge's song. Deliberately NOT part of the generator's Random stream: the
/// day and the song name are hashed together and the highest-scoring song wins ("rendezvous
/// hashing"), which consumes no rng draws and buys two things:
///
///  • ADDING SONGS IS SAFE — dropping a track into Assets/music only changes the days that track
///    outright wins (~1 day in N for an N-song library); every other past day keeps its song, and no
///    generation draw shifts at all because none of this touches the generator's Random.
///  • NO RECENT REPEATS — a day skips whatever the previous <see cref="RepeatWindowDays"/> days
///    picked, so a track can't come back the next morning.
///
/// The skip list is built from earlier days' one-level-shallower picks rather than chaining back
/// through all history, the same best-effort trick <see cref="DailyLevelGenerator"/> uses for
/// templates: every day stays a pure function of its own date. The cost is a rare miss — sweeps over
/// 5000 days with the current library show no back-to-back repeat at all and about one repeat inside
/// the window per decade.
/// </summary>
public static class DailyMusic
{
	/// <summary>How many previous days' songs a day avoids, capped at a third of the library so a
	/// small music folder still has room to pick from.</summary>
	public const int RepeatWindowDays = 10;

	/// <summary>Songs the daily never picks (shelve a track without moving the file). Bare names, no
	/// extension.</summary>
	private static readonly string[] Excluded = Array.Empty<string>();

	// Song library + a matching table of name hashes, latched on the first scan that finds files (the
	// mounted filesystem can lag at boot — see DailyTemplates.EnsureLoaded for the same race).
	private static List<string> _pool;
	private static uint[] _poolHashes;
	private static float _lastScanTime = float.NegativeInfinity;

	// Memoised per-day picks: one day's answer needs ~3 windows' worth of earlier-day picks, and the
	// hub/daily_dump ask for neighbouring days constantly.
	private const int CacheLimit = 4096;
	private static readonly Dictionary<string, string> _rawPicks = new();
	private static readonly Dictionary<string, string> _windowPicks = new();

	/// <summary>Songs the daily draws from, in a stable order (empty until the library mounts).</summary>
	public static IReadOnlyList<string> Pool
	{
		get
		{
			EnsureLoaded();
			return (IReadOnlyList<string>)_pool ?? Array.Empty<string>();
		}
	}

	public static bool IsLoaded => _pool is not null;

	/// <summary>Re-scan Assets/music (hotload hygiene / after dropping in new tracks).</summary>
	public static void Reload()
	{
		_pool = null;
		_poolHashes = null;
		_lastScanTime = float.NegativeInfinity;
		_rawPicks.Clear();
		_windowPicks.Clear();
	}

	/// <summary>The song for a day, or null when the library hasn't mounted yet (the caller then
	/// falls back to <see cref="Audio.DefaultMusic"/>).</summary>
	public static string For( string dailyId )
	{
		EnsureLoaded();
		if ( _pool is null || _pool.Count == 0 ) return null;

		return Best( dailyId, Recent( dailyId, WindowPick ) );
	}

	/// <summary>The window of days a repeat is avoided across — the constant, kept under a third of
	/// the library so the skip list can never crowd out the pool.</summary>
	private static int Window => Math.Max( 1, Math.Min( RepeatWindowDays, _pool.Count / 3 ) );

	/// <summary>What the previous <see cref="Window"/> days picked, at the given level of detail.</summary>
	private static HashSet<string> Recent( string dailyId, Func<string, string> pickFor )
	{
		var recent = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		string id = dailyId;
		for ( int i = 0; i < Window; i++ )
		{
			id = DailyChallenge.AddDays( id, -1 );
			recent.Add( pickFor( id ) );
		}
		return recent;
	}

	// One level shallower than the real pick: skips the previous days' RAW songs. Only used to build
	// the skip list, which is why the recursion stops here instead of running back through history.
	private static string WindowPick( string dailyId )
	{
		if ( _windowPicks.TryGetValue( dailyId, out var cached ) ) return cached;

		var pick = Best( dailyId, Recent( dailyId, RawPick ) );
		if ( _windowPicks.Count >= CacheLimit ) _windowPicks.Clear();
		_windowPicks[dailyId] = pick;
		return pick;
	}

	// The day's song with nothing skipped.
	private static string RawPick( string dailyId )
	{
		if ( _rawPicks.TryGetValue( dailyId, out var cached ) ) return cached;

		var pick = Best( dailyId, null );
		if ( _rawPicks.Count >= CacheLimit ) _rawPicks.Clear();
		_rawPicks[dailyId] = pick;
		return pick;
	}

	/// <summary>Highest-scoring song for a day, ignoring anything in <paramref name="recent"/>.</summary>
	private static string Best( string dailyId, HashSet<string> recent )
	{
		uint seed = DaySeed( dailyId );
		string best = null;
		uint bestScore = 0;

		for ( int i = 0; i < _pool.Count; i++ )
		{
			if ( recent is not null && recent.Contains( _pool[i] ) ) continue;

			uint score = Mix( _poolHashes[i] ^ seed );
			// Ties break on name so the result never depends on scan order alone.
			if ( best is null || score > bestScore
				|| (score == bestScore && string.CompareOrdinal( _pool[i], best ) < 0) )
			{
				best = _pool[i];
				bestScore = score;
			}
		}

		return best ?? _pool[0];
	}

	// Day seed, mixed from the yyyyMMdd number alone: unlike the generator seed this deliberately does
	// NOT fold in LEADERBOARD_VERSION, so a version bump re-rolls the layouts and leaves the songs.
	private static uint DaySeed( string dailyId )
	{
		if ( !int.TryParse( dailyId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int day ) )
			day = 0;

		return Mix( (uint)day ^ 0x50C0DA11u );
	}

	// FNV-1a over the lower-cased name: a fixed hash, unlike string.GetHashCode (randomised per
	// process), so every machine scores the same song identically.
	private static uint NameHash( string name )
	{
		unchecked
		{
			uint h = 2166136261u;
			foreach ( char c in name )
			{
				h ^= char.ToLowerInvariant( c );
				h *= 16777619u;
			}
			return h;
		}
	}

	private static uint Mix( uint x )
	{
		unchecked
		{
			x ^= x >> 16;
			x *= 0x7FEB352Du;
			x ^= x >> 15;
			x *= 0x846CA68Bu;
			x ^= x >> 16;
			return x;
		}
	}

	private static void EnsureLoaded()
	{
		if ( _pool is not null ) return;
		// Retry at most once a second: a missing library must not cost a filesystem scan per frame
		// (DailyLevels.Get sits on hot paths).
		if ( RealTime.Now - _lastScanTime < 1f ) return;
		_lastScanTime = RealTime.Now;

		// Sorted, extension-free, identical on every machine; the menus' default song is excluded.
		var names = Audio.RandomizableMusicNames();
		names.RemoveAll( n => Excluded.Contains( n, StringComparer.OrdinalIgnoreCase ) );
		if ( names.Count == 0 ) return;      // nothing mounted yet (or nothing left): retry later

		_pool = names;
		_poolHashes = names.Select( NameHash ).ToArray();
	}
}