/* 
 * Copyright (c)2009-2025 DemiVision, LLC. All Rights Reserved. The information 
 * herein is the CONFIDENTIAL and PROPRIETARY information of DemiVision, LLC. 
 */

using System.Text.Json.Serialization;
using Plugin.Firebase.Firestore;

using DXLib.Data;
using DXLib.Data.Model;

using DXLib.Log;
using DXLib.Utils;

namespace iStatVball3;

/*
 * Represents an individual set within a volleyball match. There will always be between 1 and 5 sets in a match. Set is
 * the parent of all recorded stats.
 *
 * Stats are persisted as a sub-collection while a set is in-progress to ease CRUD operations. Once a set has ended,
 * stats are serialized to a compressed file and saved to Firebase Cloud Storage to improve fetch performance. Stats are
 * also cached locally. The sub-collection can then optionally be deleted.
 */
public class Set : DXImageModel
{
	/* Constants */
	public const string CollectionKey = "Sets";

	// End result (for Team 1)
	public enum SetResult
	{
		Won,
		Lost,
		Tied
	};

	// Default duration (min) for non-live sets
	private const int Duration = 30;

	/* Properties */

	// Set info
	[FirestoreProperty("Imported")] public bool Imported { get; set; }
	[FirestoreProperty("Legacy")] public bool Legacy { get; set; }
	[FirestoreProperty("Synced")] public bool Synced { get; set; }

	[FirestoreProperty("Connected")] public bool? Connected { get; set; }

	[FirestoreProperty("Version")] public string Version { get; set; }
	[FirestoreProperty("RallyLevel")] public string RallyLevel { get; set; }

	// Not team specific
	[FirestoreProperty("Number")] public int Number { get; set; }

	[FirestoreProperty("StartTime")] public DateTimeOffset? StartTime { get; set; }
	[FirestoreProperty("EndTime")] public DateTimeOffset? EndTime { get; set; }

	[FirestoreProperty("Archived")] public DateTimeOffset? Archived { get; set; }

    // All team specific fields
    [FirestoreProperty("Serve1")] public bool Serve1 { get; set; }
    [FirestoreProperty("Serve2")] public bool Serve2 { get; set; }

    [FirestoreProperty("Side1")] public string Side1 { get; set; }
    [FirestoreProperty("Side2")] public string Side2 { get; set; }

    [FirestoreProperty("Type1")] public string Type1 { get; set; }
    [FirestoreProperty("Type2")] public string Type2 { get; set; }

    [FirestoreProperty("Rotation1")] public int Rotation1 { get; set; }
    [FirestoreProperty("Rotation2")] public int Rotation2 { get; set; }

    [FirestoreProperty("Notes1")] public string Notes1 { get; set; }
    [FirestoreProperty("Notes2")] public string Notes2 { get; set; }

	// Results
	[FirestoreProperty("Result")] public int Result { get; set; }

	[FirestoreProperty("Points1")] public int Points1 { get; set; }
	[FirestoreProperty("Points2")] public int Points2 { get; set; }

	// Video settings
	[FirestoreProperty("Video")] public SetVideo Video { get; set; }

	// Starting lineup (array of maps)
	[FirestoreProperty("Starters")] public IList<LineupEntry> Starters { get; set; }

	// References (FKs)
	[FirestoreProperty("Lineup1Id")] public string Lineup1Id { get; set; }
	[FirestoreProperty("Lineup2Id")] public string Lineup2Id { get; set; }

	// Summarized raw stats (map)
	[FirestoreProperty("StatSummary")] public StatSummary StatSummary { get; set; }

	// Parent (FK)
	[FirestoreProperty("MatchId")] public string MatchId { get; set; }

	// Flattened (FK)
	[FirestoreProperty("SeasonId")] public string SeasonId { get; set; }

	/* Ignored */
	[JsonIgnore] public string Name => $"{DXString.Get( "set.singular" )} {Number}";
	[JsonIgnore] public string Detail => (IsNew || (StartTime == null)) ? null : DXUtils.TimeFromDate( (DateTimeOffset)StartTime );

	// State
	[JsonIgnore] public bool IsNew => StartTime == null;
	[JsonIgnore] public bool IsEnded => EndTime != null;
	[JsonIgnore] public bool IsInProgress => !IsNew && !IsEnded;

	[JsonIgnore] public bool IsRallyLow => RallyLevel is Settings.LowKey;
	[JsonIgnore] public bool IsSample => Match.Season.IsSample;

	[JsonIgnore] public bool IsSynced => (Synced && (Video != null));
	[JsonIgnore] public bool IsRallySynced => (Match.IsRallySynced && IsSynced);
	
	// Record
	[JsonIgnore] public bool Won => IsEnded && (Result == (byte)SetResult.Won);
	[JsonIgnore] public bool Lost => IsEnded && (Result == (byte)SetResult.Lost);
	[JsonIgnore] public bool Tied => IsEnded && (Result == (byte)SetResult.Tied);

	[JsonIgnore] public int TotalPoints => (Points1 + Points2);

	// Rosters locked at start of set
	[JsonIgnore] public List<Player> Roster1 => Match.Season.GetActivePlayers();
	[JsonIgnore] public static List<Player> Roster2 => null;

	// References
	[JsonIgnore] public Lineup Lineup1 { get; set; }
	[JsonIgnore] public Lineup Lineup2 { get; set; }

	// Parent
	[JsonIgnore] public Match Match { get; set; }

	// Stats (stack)
	[JsonIgnore] public List<Stat> StatCache { get; set; }
	[JsonIgnore] public int StatCount => StatCache.Count;

	// Have stats been archived to cloud, deleted from db?
	[JsonIgnore] public bool IsArchived => (Deleted != null) || (Archived != null);

	/* Methods */
	public Set()
	{
		BaseCollectionKey = CollectionKey;

		// Allocate containers
		Starters = new List<LineupEntry>();
		StatCache = [];
	}

	// Tests equality based on unique ID
	public override bool Equals( object obj )
	{
		return (obj is Set set) && set.UniqueId.Equals( UniqueId );
	}

	// Generates unique hash code, required for Equals
	public override int GetHashCode()
	{
		return UniqueId.GetHashCode();
	}

	/* Start */

	// Starts set in real-time
	public async Task Start()
	{
		await Start( DateTimeOffset.Now );
	}

	// Marks set (and parent match) as started, now in-progress
	public async Task Start( DateTimeOffset time )
	{
		IWriteBatch batch = DXData.StartBatch();

		// Start parent match (first set only)
		if ( Number == 1 )
		{
			Match.UpdateStart( batch, time );
		}

		// Start set
		UpdateStart( batch, time );

		// Persist
		await DXData.CommitBatch( batch );
	}

	// Calculates starting time for non-live sets
	public DateTimeOffset CalcStartTime()
	{
		int minutes = ((Number - 1) * Duration);

		// 30min per set
		return Match.MatchTime.AddMinutes( minutes );
	}

	// Returns list of players on court (and libero area) at first point
	public async Task<IList<string>> GetStartingPlayers()
	{
		await ReadCache();

		foreach ( Stat stat in StatCache )
		{
			// Use IDs from first point
			if ( stat.IsPoint )
			{
				return stat.Point.PlayerIds1;
			}
		}

		return null;
	}

	// Returns full starting lineup (including positions, numbers)
	public async Task<Lineup> GetStartingLineup()
	{
		// Use actual lineup at first point
		if ( Starters.Count > 0 )
		{
			List<LineupEntry> entries = [];

			// Add zone numbers
			for ( int zone = 1; zone <= Lineup.MaxEntries; zone++ )
			{
				int index = (zone - 1);

				if ( index < Starters.Count )
				{
					LineupEntry entry = Starters[ index ];
					entry.Zone = zone;

					entries.Add( entry );
				}
			}

			// Create
			return new Lineup
			{
				Entries = entries
			};
		}
		
		// Use pre-configured lineup
		if ( Lineup1 != null )
		{
			return Lineup1;
		}
		
		// Build lineup
		if ( IsEnded )
		{
			List<LineupEntry> entries = new();

			// Use starting players
			IList<string> playerIds = await GetStartingPlayers();

			// Add position/number from roster
			foreach ( string playerId in playerIds )
			{
				Player player = Match.Season.GetPlayer( playerId );

				if ( player != null )
				{
					LineupEntry entry = new( player );
					entries.Add( entry );
				}
			}

			// Create
			return new Lineup
			{
				Entries = entries
			};
		}

		// No lineup available
		return null;
	}

	/* End */

	// Ends set in real-time
	public async Task End( int point1, int points2 )
	{
		await End( DateTimeOffset.Now, point1, points2 );
	}

	// Marks set as ended at specified time
	public async Task End( DateTimeOffset time, int points1, int points2 )
	{
		// 1. Immediately cache locally first for safety
		CacheLocal();

		// 2. Then write to cloud
		bool archived = await CacheCloud( false );

		// 3. IFF successfully archived, delete raw stats from Firestore
		if ( archived )
		{
			await DeleteStats();
		}

		// 4. Update Firestore
		await UpdateEnd( time, points1, points2, archived );
	}

	// Calculates ending time for non-live sets
	public DateTimeOffset CalcEndTime()
	{
		return StartTime?.AddMinutes( Duration - 1 ) ?? DateTimeOffset.MaxValue;
	}

	/* Result */

	// Returns score in format '25-15'
	public string GetScore()
    {
		return $"{Points1}-{Points2}";
	}

	// Sets current point total for both teams
	public void SetScore( int points1, int points2 )
	{
		Points1 = points1;
		Points2 = points2;

		Match.RefreshSetScores();
	}
	
	// Determine set winner/loser based on points
	private SetResult CalcResult()
	{
		// Team1 won
		if ( Points1 > Points2 )
		{
			return SetResult.Won;
		}

		// Team2 won
		if ( Points2 > Points1 )
		{
			return SetResult.Lost;
		}

		// Tied (should never happen)
		return SetResult.Tied;
	}

	/* Permissions */

	// Determines if user has permission to create Sets
	public static bool CanCreate( Match match, User user )
	{
		return user.Level switch
		{
			// Director/coach/stat can
			User.LevelType.Director or 
			User.LevelType.Coach or 
			User.LevelType.Statistician => !match.Season.IsSample || user.IsAdmin,
				
			// No one else can
			_ => false
		};
	}

	// Determines if user has permission to edit Sets
	public static bool CanEdit( User user )
	{
		return user.Level switch
		{
			// Director/coach/stat always can
			User.LevelType.Director or 
			User.LevelType.Coach or 
			User.LevelType.Statistician => true,
			
			// No one else can
			_ => false
		};
	}

	// Determines if user has permission to analyze Set stats
	public bool CanAnalyze( User user )
	{
		return user.Level switch
		{
			// Media cannot
			User.LevelType.Media => false,
			
			// Statistician only if linked to player
			User.LevelType.Statistician => user.HasPermission( Match.Season ),
			
			// Everyone else can
			_ => true
		};
	}

	// Determines if user has permission to Record stats
	public bool CanRecord( User user )
	{
		return user.Level switch
		{
			// Fan/Player/Media cannot
			User.LevelType.Director or User.LevelType.Coach or User.LevelType.Statistician => true,
			
			_ => IsSample
		};
	}

	// Determines if user has permission to Watch live
	public static bool CanWatch( User user )
	{
		// Only Fan (or higher levels in Fan Mode)
		return user.IsFan;
	}

	/* Analysis */

	// Aggregates all data for analyzing the scope of this Set
	public async Task<DataStats> Aggregate( bool force = false )
	{
		DataStats stats = new();

		// Read all analysis (via query or cache)
		await ReadCache( force );

		// Build list
		if ( StatCache is { Count: > 0 } )
		{
			stats.AddRange( StatCache );

			stats.AddSets( 1 );
			stats.AddPoints( TotalPoints );
			stats.AddRally( Legacy ? 0 : 1 );
		}
		// Special handling for paper stats
		else if ( Match.IsPaper )
		{
			stats.AddSets( 1 );
			stats.AddRally( 0 );
		}

		return stats;
	}

	// Aggregates all raw summary data for scope of this Set
	public StatSummary AggregateRaw()
	{
		// Deep copy if summary data available
		return (StatSummary == null) ? null : new StatSummary( StatSummary )
		{
			SetCount = 1
		};
	}

	/* Populate */

	// Populates all references for this Set (no db)
	public void Populate( Match match )
	{
		Match = match;

		Season season = match.Season;

		// Lineups
		Lineup1 = season.GetLineup( Lineup1Id );
		Lineup2 = null;

        // Starting lineup (at first point)
        if ( Starters != null )
        {
            foreach ( LineupEntry entry in Starters )
            {
                entry.Populate( season );
            }
        }
    }

    // Populates stats list from sub-collection for recording
    public async Task PopulateStats( bool force = false, bool deep = true )
	{
		// NA for imported sets
		if ( Imported )
		{
			return;
		}

		#if DEBUG
			DXProfiler.Start( true );
		#endif

		// New set never requires query, in-progress uses existing stats
		if ( IsNew )
		{
			StatCache = [];
		}
		// Only needed if not already in-memory (or being forced)
		else if ( StatCache is { Count: 0 } || force )
		{
			// Only valid if in-progress
			if ( IsInProgress )
			{
				// Read (sorted ascending by timestamp)
				StatCache = await ReadStats();
			}

			// Populate FKs
			PopulateStatFKs( deep );
		}

		#if DEBUG
			DXProfiler.Mark( "set.stats", true );
		#endif
	}

	// Used internally to populate FKs for all in-memory stats
	public void PopulateStatFKs( bool deep = true )
	{
		if ( StatCache != null )
		{
			foreach ( Stat stat in StatCache )
			{
				stat.Populate( this, deep );
			}
		}
	}

	/* Cache */

	// Reads stats for ended set from multi-level cache in order:
	//   1. Local memory
	//   2. Local disk cache
	//   3. Firebase cloud storage
	//   4. Firestore Db
	//
	public async Task ReadCache( bool force = false )
	{
		// NA for imported sets
		if ( Imported )
		{
			return;
		}

		#if DEBUG
			DXProfiler.Start( true );
			string src = "mem";
		#endif

		// New set (or tally stats only) does not require query
		if ( (StatCache == null) || IsNew )
		{
			StatCache = [];
		}
		// Load only necessary if not already IN-MEMORY
		else if ( (StatCache.Count == 0) || force )
		{
			// In-progress set has not been cached yet
			if ( IsInProgress )
			{
				await PopulateStats( force );
			}
			else if ( IsEnded )
			{
				#if DEBUG
					src = "local";
				#endif
				
				// Try LOCAL cache first
				SetData data = SetData.ReadLocal( UniqueId );

				bool cloudMiss = false;
				bool localMiss = (data == null) || data.IsEmpty;

				// Local cache miss
				if ( localMiss )
				{
					#if DEBUG
						src = "cloud";
					#endif

					// Try CLOUD cache next (ignore not found exception)
					data = await SetData.ReadCloud( UniqueId, false );

					cloudMiss = (data == null) || data.IsEmpty;

					// Cloud cache miss
					if ( cloudMiss )
					{
						#if DEBUG
							src = "db";
						#endif

						data = null;

						// Try Firestore DB last
						StatCache = await ReadStats();

						bool dbMiss = (StatCache == null) || (StatCache.Count == 0);

						// Should never happen
						if ( dbMiss )
						{
							DXLog.Exception( "set.dbmiss", null );
						}
						else
						{
							data = new SetData
							{
								SetId = UniqueId,
								Stats = StatCache
							};
						}
					}
				}

				if ( data is { IsEmpty: false } )
				{
					// Cache in-memory
					StatCache = data.Stats.ToList();

					// Populate FKs
					PopulateStatFKs();

					// Cache locally
					if ( localMiss )
					{
						CacheLocal( data );
					}

					// May need to cache to cloud
					if ( cloudMiss )
					{
						await CacheCloud( false );
					}
				}
			}
		}

		#if DEBUG
			DXProfiler.Mark( $"set.readcache src:{src} stats:{StatCache?.Count}", true );
		#endif
	}	

	// Writes all stats to both local and cloud caches
	public async Task WriteCaches( bool replace )
	{
		// Local
		CacheLocal( replace );

		// Cloud
		await CacheCloud( replace );
	}

	// Caches set stats to local disk for fast query and restore
	public void CacheLocal( bool replace = true )
	{
		SetData data = CreateSetData();

		CacheLocal( data, replace );
	}

	// Caches specified stats to local disk for fast query and restore
	public void CacheLocal( SetData data, bool replace = true )
	{
		// Write to disk
		if ( !data.IsEmpty )
		{
			data.WriteLocal( replace );
		}

		// Also cache match metadata for emergency restore
		Match.CacheLocal();
	}

	// Persists set data to remote cloud storage
	public async Task<bool> CacheCloud( bool replace )
	{
		SetData data = CreateSetData();

		// NA for empty stats
		if ( data.IsEmpty )
		{
			return false;
		}

		// MUST have connection, NA for paper stats
		if ( DXData.HasConnection() && (Match is not { IsPaper: true }) )
		{
			// No longer need State objects
			foreach ( Stat stat in data.Stats )
			{
				stat.State = null;
			}

			// Persist to cloud
			await data.WriteCloud( replace );

			// Read data back to verify persistence
			SetData verifiedData = await SetData.ReadCloud( UniqueId );

			int count = data.Count;
			int verified = verifiedData.Count;
			
			// Leave in db if not cached (delete later via admin archive)
			if ( verified == count )
			{
				return true;
			}
			
			DXLog.Error( "set.cachecloud", "count:{0} verified:{1}", count, verified );
		}
		
		return false;
	}

	// Returns a SetData object populated for caching
	private SetData CreateSetData()
	{
        // Object for file
        SetData data = new()
        {
            SetId = UniqueId,

            // Copy stats to map array
            Stats = StatCache?.ToList()
        };

        return data;
	}

	/* CRUD */

	// READ

	// Read all stats from document sub-collection (sorted ascending by timestamp)
	public async Task<List<Stat>> ReadStats()
	{
		return await ReadSubChildren<Stat>( Stat.CollectionKey, "SetId", "Created" );
	}

	// UPDATE

	// Updates set starting time (batched)
	private void UpdateStart( IWriteBatch batch, DateTimeOffset start )
	{
		StartTime = start;

		Update( batch, "StartTime", StartTime );
		Update( batch, "Connected", DXData.HasConnection() );
		Update( batch, "Deleted", null );						// Required for archiving
	}

	// Updates all data required to end set (internally batched)
	private async Task UpdateEnd( DateTimeOffset? time, int points1, int points2, bool archived )
	{
		IWriteBatch batch = DXData.StartBatch();

		// End set
		EndTime = time;

		Points1 = (byte)points1;
		Points2 = (byte)points2;

		// Determine set winner
		Result = (byte)CalcResult();

		// Deleted timestamp used to indicate stat archival
		Deleted = archived ? time : null;

		// Update entire set object
		Update( batch );

		// Update match scores/result
		Match.UpdateScores( batch );
		Match.UpdateResult( batch );

		// Persist
		await DXData.CommitBatch( batch );
	}

	// Updates archival time for this set
	public async Task UpdateArchived()
	{
		Archived = DXUtils.Now();

		await Update( "Archived", Archived );
	}

	// Updates set number following deletion (batched)
	public void UpdateNumber( IWriteBatch batch, byte number )
	{
		Number = number;

		Update( batch, "Number", Number );
	}

	// Updates current set score for both teams, MUST be pre-calculated (batched)
	public void UpdateScore( IWriteBatch batch )
	{
		Match.UpdateScores( batch );

		Update( batch, "Points1", Points1 );
		Update( batch, "Points2", Points2 );
	}

	// Updates set result (points must already be updated)
	public async Task UpdateResult()
	{
		IWriteBatch batch = DXData.StartBatch();

		// Update set result
		Result = (byte)CalcResult();
		Update( batch, "Result", Result );

		// Must also update match
		Match.UpdateScores( batch );
		Match.UpdateResult( batch );

		// Update win/loss record up tree
		if ( IsEnded )
		{
			Match.UpdateRecord( batch );
		}

		await DXData.CommitBatch( batch );
	}

	// Updates raw stat summary field
	public async Task UpdateSummary()
	{
		await Update( "StatSummary", StatSummary );
	}

	// Updates opposing team for this set (no db)
	public async Task UpdateOpponent( Opponent opponent )
	{
		// Cannot update while in progress
		if ( !IsInProgress )
		{
			// Make sure stats fully populated (MUST be first)
			await ReadCache();

			string oldId = Match.OpponentId;
			string newId = opponent?.UniqueId;

			// Update each underlying stat
			foreach ( Stat stat in StatCache )
			{
				stat.UpdateTeam( oldId, newId );
			}

			// Cache updated stats to local/cloud
			await WriteCaches( true );
		}
	}

	// Updates starting lineup (batched)
	public void UpdateStarters( IWriteBatch batch, RecordLineup lineup )
    {
	    // First point only, deep copy lineup
		if ( Starters.Count == 0 )
		{
			foreach ( LineupEntry entry in lineup.Entries )
			{
				Starters.Add( new LineupEntry( entry ) );
			}

			Update( batch, "Starters", Starters );
		}
    }

	// DELETE

	// Performs cascading delete for this Set
	public override async Task Delete( bool remove = true )
	{
		// Imported set deletion NOT batched
		if ( Imported )
		{
			if ( remove )
			{
				Match.Sets.Remove( this );
				Match.Season.Sets.Remove( this );
			}

			await base.Delete( remove );
		}
		// Batched
		else
		{
			// Delete child stats (sub-collection)
			await DeleteStats();

			// Delete from cloud cache
			await SetData.DeleteCloud( this );

			// Do NOT delete from local cache (used for restore)

			// Remove from parent
			if ( remove )
			{
				Match.Sets.Remove( this );
				Match.Season.Sets.Remove( this );
			}

			IWriteBatch batch = DXData.StartBatch();

			// Reset match if now empty
			if ( Match.SetCount == 0 )
			{
				Match.UpdateStart( batch, null );
			}

			// Delete self
			Delete( batch );

			// Persist
			await DXData.CommitBatch( batch );
		}
	}

	// Deletes recording stats sub-collection (independently batched)
	public async Task DeleteStats()
	{
		if ( DXData.HasConnection() )
		{
			// Ensure all stats pre-populated
			await PopulateStats( true, false );

			if ( StatCache is { Count: > 0 } )
			{
				int index = 0;
				int count = StatCache.Count;

				// Must chunk batch deletion
				while ( index < count )
				{
					IWriteBatch batch = DXData.StartBatch();

					// Delete in chunks of 500
					for ( int i = 0; i < DXData.MaxBatchSize; i++ )
					{
						if ( index >= count )
						{
							break;
						}

						// Delete individual stat
						StatCache[ index ].Delete( batch, false );

						index++;
					}

					await DXData.CommitBatch( batch );
				}

				StatCache.Clear();
			}
		}
	}
}

//
