Park/ParkManager.cs
using HC3;
using HC3.Inventory;
using HC3.Terraforming;
using HC3.Terrain;
using Sandbox.Diagnostics;
using System;

/// <summary>
/// A collection of park related events.
/// </summary>
public interface IParkEvents : ISceneEvent<IParkEvents>
{
	/// <summary>
	/// Called when an AI agent enters the park -- this could be a Guest, a Staff member.
	/// </summary>
	/// <param name="agent"></param>
	public void OnAgentEnter( Agent agent ) { }

	/// <summary>
	/// Called when an AI agent leaves the park -- this could be a Guest, a Staff member.
	/// </summary>
	/// <param name="agent"></param>
	public void OnAgentExit( Agent agent ) { }

	/// <summary>
	/// Called when the park opens.
	/// </summary>
	public void OnParkOpened() { }

	/// <summary>
	/// Called when the park closes.
	/// </summary>
	public void OnParkClosed() { }

	/// <summary>
	/// Called when the park's name changes.
	/// </summary>
	/// <param name="name"></param>
	public void OnNameChanged( string name ) { }

	/// <summary>
	/// Called whne the park's rating changes. Max is 5.
	/// </summary>
	/// <param name="rating"></param>
	void OnParkRatingChanged( float rating ) { }
}

/// <summary>
/// A manager class for the park itself. Handles admission, money, rating, and other park-wide systems.
/// </summary>
public sealed partial class ParkManager : Component, ITimeOfDayEvents
{
	/// <summary>
	/// Singleton instance of <see cref="ParkManager"/>
	/// </summary>
	public static ParkManager Instance { get; private set; }

	/// <summary>
	/// The name of the park
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public string Name
	{
		get => _name;
		set
		{
			_name = value;
			IParkEvents.Post( x => x.OnNameChanged( _name ) );
		}
	}

	/// <summary>
	/// Whether or not the park is open
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public OpenState OpenState { get; set; } = OpenState.Open;

	/// <summary>
	/// The guest's price of admission to the park
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public int AdmissionFee { get; set; } = 50;

	/// <summary>
	/// How much money the player has to spend on their park
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public int Money { get; private set; } = 100_000;

	/// <summary>
	/// The current rating of the park, from 0 to 5
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public float Rating { get; private set; }

	/// <summary>
	/// How much have we earnt today?
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public float DailyIncome { get; set; } = 0f;

	/// <summary>
	/// The cost of keeping the park running each day. Automatically deducted from the player's money at the start of each day.
	/// </summary>
	public int DailyOperationalCosts
	{
		get
		{
			var cost = 0;
			foreach ( var building in Scene.GetAllComponents<Building>() )
			{
				cost += (int)MathF.Floor( building.Cost / 5f );
			}
			foreach ( var path in Scene.GetAllComponents<Path>() )
			{
				cost += 2;
			}
			cost += GuestManager.Instance.GetGuestTarget() * 5;
			return cost;
		}
	}

	RealTimeSince _timeSinceLastRatingUpdate = 0;
	bool _wasParkOpen;
	string _name = "My Park";

	/// <summary>
	/// A debug command to give money to the player.
	/// </summary>
	/// <param name="amount"></param>
	[ConCmd( "hc3.debug.addmoney", ConVarFlags.Cheat )]
	public static void AddMoney( int amount )
	{
		Assert.True( Networking.IsHost );
		if ( amount <= 0 ) return;
		ParkManager.Instance.GiveMoney( amount, "Debug" );
	}

	protected override void OnAwake()
	{
		Instance = this;
	}

	/// <summary>
	/// Was the park open last frame?
	/// </summary>

	/// <summary>
	/// Is the park open? This takes into account its open state and the time of day.
	/// </summary>
	public bool IsOpen()
	{
		return OpenState == OpenState.Open;
	}

	protected override void OnFixedUpdate()
	{
		if ( _wasParkOpen && !IsOpen() )
		{
			_wasParkOpen = false;
			IParkEvents.Post( x => x.OnParkClosed() );
		}
		else if ( !_wasParkOpen && IsOpen() )
		{
			_wasParkOpen = true;
			IParkEvents.Post( x => x.OnParkOpened() );
		}

		// Only the host should handle this logic.
		if ( !Networking.IsHost )
			return;

		if ( _timeSinceLastRatingUpdate > 4f )
		{
			_timeSinceLastRatingUpdate = 0f;
			UpdateParkRating();
		}
	}

	/// <summary>
	/// Destroy a park object. This will be called on the host.
	/// </summary>
	/// <param name="target"></param>
	[Rpc.Host]
	public void DestroyObject( Component target )
	{
		if ( !target.IsValid() ) return;

		var pos = target.WorldPosition;
		var dust = GameObject.Clone( "prefabs/particles/place_dustcloud.prefab", new Transform( pos + Vector3.Up * 8 ) );
		Sound.Play( "sounds/gameplay/building_placed.sound", pos );

		if ( target is Building building )
		{
			building.GameObject.Destroy();
		}
		else if ( target is TerrainScenery scenery )
		{
			TakeMoney( scenery.DestructionCost, "Landscaping" );

			scenery.GameObject.Destroy();

			return;
		}
		else if ( target is PathFurniture furniture )
		{
			furniture.GameObject.Destroy();
		}
		else if ( target is Decoration decoration )
		{
			decoration.GameObject.Destroy();
		}
	}

	/// <summary>
	/// Places a decoration in the park. I don't think this belongs in ParkManager. Why does it have so many fucking arguments.
	/// </summary>
	/// <param name="resourcePath"></param>
	/// <param name="position"></param>
	/// <param name="angle"></param>
	/// <param name="pitch"></param>
	/// <param name="height"></param>
	/// <param name="tint"></param>
	/// <param name="red"></param>
	/// <param name="green"></param>
	/// <param name="blue"></param>
	/// <param name="alpha"></param>
	[Rpc.Host]
	public void PlaceDecoration( string resourcePath, Vector3 position, int angle, int pitch, int height, Color tint, Color red, Color green, Color blue, Color alpha )
	{
		if ( string.IsNullOrEmpty( resourcePath ) )
			return;

		var prefab = GameObject.GetPrefab( resourcePath );
		var resource = prefab.GetComponentInChildren<Decoration>();

		if ( !TakeMoney( resource.Cost, "Decorations" ) ) return;

		var realDecoration = prefab.Clone( position, new Angles( pitch, angle, 0f ) ).GetComponent<Decoration>();
		realDecoration.Tags.Add( "decoration" );
		realDecoration.Tags.Add( "selectable" );

		realDecoration.RedChannel = new() { Color = red, Enabled = true };
		realDecoration.GreenChannel = new() { Color = green, Enabled = true };
		realDecoration.BlueChannel = new() { Color = blue, Enabled = true };
		realDecoration.AlphaChannel = new() { Color = alpha, Enabled = true };

		var tintComponent = realDecoration.GetTintComponent();
		if ( tintComponent.IsValid() )
		{
			tintComponent.PrimaryColor = red;
			tintComponent.SecondaryColor = green;
			tintComponent.AccentColor = blue;
			tintComponent.DetailColor = alpha;
		}

		MoneyEffect.Broadcast( position + Vector3.Up * 100f, $"-{GameUtils.Currency}{resource.Cost}", Color.Red );

		Sound.Play( GetPlaceSound( realDecoration.ObjectMaterial ) );

		realDecoration.IsPlaced = true;

		realDecoration.GameObject.NetworkSpawn();
	}

	/// <summary>
	/// Places a furniture object at the specified grid position and edge within the park.
	/// Same as <see cref="PlaceDecoration(string, Vector3, int, int, int, Color, Color, Color, Color, Color)"/> -- why is it on this class?
	/// </summary>
	/// <param name="resourcePath"></param>
	/// <param name="gridPos"></param>
	/// <param name="edge"></param>
	[Rpc.Host]
	public void PlaceFurniture( string resourcePath, Vector3Int gridPos, TileEdge edge )
	{
		if ( string.IsNullOrEmpty( resourcePath ) )
			return;

		var prefab = GameObject.GetPrefab( resourcePath );
		var resource = prefab.GetComponentInChildren<PathFurniture>();

		if ( !TakeMoney( resource.Cost, "Furniture" ) ) return;

		var cell = GridManager.Instance.GetCell( new Vector2Int( gridPos ) );
		var path = cell?.GetComponent<Path>( gridPos.z );

		if ( path is null )
		{
			Log.Warning( $"Tried to place furniture {resourcePath} on a path that doesn't exist at {gridPos}." );
			return;
		}

		var direction = edge.GetDirection();
		var gameObject = prefab.Clone(
			path.WorldPosition + new Vector3( direction * GridManager.CentreOffset * PathFurniture.GetPathOffset( resource.FurnitureType ) ),
			Rotation.FromYaw( MathF.Atan2( direction.y, direction.x ).RadianToDegree() ) );

		gameObject.SetParent( path.GameObject );

		var instance = gameObject.GetComponent<PathFurniture>();
		instance.IsPlaced = true;
		instance.TileEdge = edge;
		instance.Tags.Add( "furniture" );
		instance.Tags.Add( "selectable" );

		MoneyEffect.Broadcast( gameObject.WorldPosition + Vector3.Up * 32, $"-{GameUtils.Currency}{resource.Cost}", Color.Red );

		Sound.Play( GetPlaceSound( ObjectMaterial.Default ) );

		gameObject.NetworkSpawn();
	}

	/// <summary>
	/// Places a placeable object in the park. Again, why is this on ParkManager?
	/// </summary>
	/// <param name="resourcePath"></param>
	/// <param name="position"></param>
	/// <param name="angle"></param>
	/// <param name="height"></param>
	[Rpc.Host]
	public void PlacePlaceable( string resourcePath, Vector3 position, int angle, int height )
	{
		if ( string.IsNullOrEmpty( resourcePath ) )
			return;

		var prefab = GameObject.GetPrefab( resourcePath );
		var resource = prefab.GetComponentInChildren<IPlacementObject>();

		if ( !resource.IsValid() )
			return;

		if ( !TakeMoney( resource.Cost, "Objects" ) ) return;

		var real = resource.GameObject.Clone( position, new Angles( 0f, angle, 0f ) ).GetComponent<IPlacementObject>();

		real.IsPlaced = true;
		real.GameObject.Tags.Add( "building" );
		real.GameObject.Tags.Add( "selectable" );

		MoneyEffect.Broadcast( position + Vector3.Up * 100f, $"-{GameUtils.Currency}{resource.Cost}", Color.Red );

		// TODO: IPlacementObject could define this
		Sound.Play( "place_object_default_1" );

		DoPlacementEffect( position + Vector3.Up * 8f, 32f );

		real.GameObject.NetworkSpawn();
	}

	/// <summary>
	/// Places a building in the park. Why is this on ParkManager?
	/// </summary>
	/// <param name="resourcePath"></param>
	/// <param name="position"></param>
	/// <param name="angle"></param>
	/// <param name="height"></param>
	[Rpc.Host]
	public void PlaceBuilding( string resourcePath, Vector3 position, int angle, int height )
	{
		if ( string.IsNullOrEmpty( resourcePath ) )
			return;

		var prefab = GameObject.GetPrefab( resourcePath );
		var resource = prefab.GetComponentInChildren<Building>();

		//if ( !CanPlace() ) return;
		if ( !TakeMoney( resource.Cost, (resource is BasicRide) ? "Rides" : "Buildings" ) ) return;

		var gridManager = GridManager.Instance;
		if ( !gridManager.IsValid() ) return;

		var realBuilding = resource.GameObject.Clone( position, new Angles( 0f, angle, 0f ) ).GetComponent<Building>();
		var range = realBuilding.GetTileRange( angle );

		for ( var x = range.Left; x < range.Right; x++ )
			for ( var y = range.Top; y < range.Bottom; y++ )
			{
				var index = new Vector2Int( x, y );
				gridManager.Terrain[index] = TerrainTile.LevelGround( height );
			}

		realBuilding.IsPlaced = true;
		realBuilding.Tags.Add( "building" );
		realBuilding.Tags.Add( "selectable" );

		var tintComponent = realBuilding.GetTintComponent();
		if ( tintComponent.IsValid() )
		{
			realBuilding.RedChannel = new TintChannelData { Color = tintComponent.PrimaryColor, Enabled = tintComponent.PrimaryEnabled };
			realBuilding.GreenChannel = new TintChannelData { Color = tintComponent.SecondaryColor, Enabled = tintComponent.SecondaryEnabled };
			realBuilding.BlueChannel = new TintChannelData { Color = tintComponent.AccentColor, Enabled = tintComponent.AccentEnabled };
			realBuilding.AlphaChannel = new TintChannelData { Color = tintComponent.DetailColor, Enabled = tintComponent.DetailEnabled };
		}

		MoneyEffect.Broadcast( position + Vector3.Up * 100f, $"-{GameUtils.Currency}{resource.Cost}", Color.Red );

		Sound.Play( GetPlaceSound( realBuilding.ObjectMaterial ) );

		DoPlacementEffect( position + Vector3.Up * 8f, 32f * Math.Max( realBuilding.Size.x, realBuilding.Size.y ) );

		realBuilding.GameObject.NetworkSpawn();

		using ( Rpc.FilterInclude( Rpc.Caller ) )
		{
			PostPlaceBuilding( realBuilding );
		}
	}

	/// <summary>
	/// Called after a building has been placed in the park.
	/// </summary>
	/// <param name="building"></param>
	[Rpc.Broadcast]
	private void PostPlaceBuilding( Building building )
	{
		BuildingPlacer.Instance?.OnBuildingPlaced( building );
	}

	/// <summary>
	/// Called after something has been placed in the park to show a dust effect.
	/// </summary>
	/// <param name="position"></param>
	/// <param name="size"></param>
	[Rpc.Broadcast]
	private void DoPlacementEffect( Vector3 position, float size )
	{
		var dust = GameObject.Clone( "prefabs/particles/place_dustcloud.prefab", new( position ), null, false );
		var ringEmitter = dust.GetComponent<ParticleRingEmitter>( true );
		ringEmitter.Radius = size;
		dust.Enabled = true;
	}

	/// <summary>
	/// Terraforms a section of terrain in the park. One would say this doesn't make sense to be on ParkManager.
	/// </summary>
	/// <param name="range"></param>
	/// <param name="tiles"></param>
	[Rpc.Host]
	public void Terraform( RectInt range, TerrainTile[] tiles )
	{
		if ( GridManager.Instance.Terrain is not { } terrain ) return;
		if ( Terraformer.Instance is not { UnitCost: var unitCost } ) return;

		var slice = new TileArraySlice( tiles, range.Size );
		var (heightDiff, paintDiff) = terrain.GetTotalDifference( range, slice );

		if ( heightDiff == 0 && paintDiff == 0 ) return;

		var totalCost = unitCost * (heightDiff + paintDiff);

		if ( !TakeMoney( totalCost, "Landscaping" ) ) return;

		var position = terrain.GridToWorld( new Vector3( range.Center, slice.GetHeightRange().Max ) );

		MoneyEffect.Broadcast( position + Vector3.Up * 100f, $"-{GameUtils.Currency}{totalCost:N0}", Color.Red );

		// TODO: calculate cost, confirm permission etc

		PostTerraform( range, tiles );
	}

	/// <summary>
	/// Called via <see cref="Terraform"/> from the host to all clients to update their terrain. Shouldn't exist here.
	/// </summary>
	/// <param name="range"></param>
	/// <param name="tiles"></param>
	[Rpc.Broadcast]
	private void PostTerraform( RectInt range, TerrainTile[] tiles )
	{
		if ( GridManager.Instance.Terrain is not { } terrain ) return;

		terrain.SetTiles( range, new TileArraySlice( tiles, range.Size ) );
	}

	public void GiveMoney( int amount, string reason = "Other" )
	{
		Assert.True( Networking.IsHost );

		Stats.Increment( "money_earned", amount );
		Stats.Increment( $"money_earned.{reason.ToIdentifier()}", amount );

		Money += amount;

		// TODO: maybe worth having events for this instead of polluting this class
		DailyIncome += amount;

		if ( Stats.Get( "park.highest_money" ) < Money )
		{
			Stats.Set( "park.highest_money", Money );
		}
	}

	/// <summary>
	/// Takes money from the player with a reason -- lets us track what they're spending on, and add stats.
	/// </summary>
	/// <param name="amount"></param>
	/// <param name="reason"></param>
	/// <returns></returns>
	public bool TakeMoney( int amount, string reason = "Other" )
	{
		Assert.True( Networking.IsHost );

		if ( Money < amount ) return false;

		Stats.Increment( "money_spent", amount );
		Stats.Increment( $"money_spent.{reason.ToIdentifier()}", amount );

		Money -= amount;
		return true;
	}

	/// <summary>
	/// Calculates a park rating based on various factors. We should add an interface here so systems can hook in.
	/// </summary>
	/// <param name="guests"></param>
	/// <param name="trashPileCount"></param>
	/// <param name="totalRides"></param>
	/// <param name="brokenRides"></param>
	/// <returns></returns>
	private float CalculateParkRating( IEnumerable<Guest> guests, int trashPileCount, int totalRides, int brokenRides )
	{
		// TODO: take into account overcrowding.

		if ( totalRides == 0 )
			return 1f; // Minimum score if no data

		var averageHappiness = GetSmoothedHappiness();
		var maxTrashBeforeFilthy = 50f;
		var cleanliness = (1f - trashPileCount / maxTrashBeforeFilthy).Clamp( 0f, 1f );
		var rideAvailability = (1f - ((float)brokenRides / totalRides)).Clamp( 0f, 1f );

		var weightedScore =
			averageHappiness * 0.6f +
			cleanliness * 0.2f +
			rideAvailability * 0.2f;

		return MathX.Lerp( 1f, 5f, weightedScore );
	}

	private readonly Queue<float> recentHappinessSamples = new();
	private readonly int maxHappinessSamples = 50;

	/// <summary>
	/// Updates the recent happiness samples used for smoothing out park rating changes.
	/// </summary>
	/// <param name="currentGuests"></param>
	private void UpdateParkHappinessSample( IEnumerable<Guest> currentGuests )
	{
		if ( !currentGuests.Any() ) return;

		var averageNow = currentGuests.Average( g => g.Needs.Happiness );
		recentHappinessSamples.Enqueue( averageNow );

		if ( recentHappinessSamples.Count > maxHappinessSamples )
			recentHappinessSamples.Dequeue();
	}

	/// <summary>
	/// Returns a smoothed average of recent happiness samples.
	/// </summary>
	/// <returns></returns>
	private float GetSmoothedHappiness()
	{
		return recentHappinessSamples.Count == 0 ? 0f : recentHappinessSamples.Average();
	}

	/// <summary>
	/// Updates the park rating based on various factors. See <see cref="CalculateParkRating"/>.
	/// </summary>
	private void UpdateParkRating()
	{
		var allGuests = Scene.GetAllComponents<Guest>();
		var trashPileCount = Scene.GetAllComponents<TrashPile>().Count();
		var allRides = Scene.GetAllComponents<BasicRide>();
		var brokenRides = allRides.Count( x => x.IsBroken );
		var timeOfDay = DayNightController.Instance.TimeOfDay;

		// If the park is open, and it's between 10am and 9pm, sample average guest happiness.
		if ( IsOpen() && timeOfDay is > 10f and < 21f )
		{
			UpdateParkHappinessSample( allGuests );
		}

		Rating = CalculateParkRating( allGuests, trashPileCount, allRides.Count(), brokenRides );
		IParkEvents.Post( x => x.OnParkRatingChanged( Rating ) );

		if ( Stats.Get( "park.highest_rating" ) < Rating )
		{
			Stats.Set( "park.highest_rating", Rating );
		}
	}

	/// <summary>
	/// Returns a global happiness level for all guests in the park.
	/// </summary>
	/// <returns></returns>
	public float GetGlobalHappinessLevel()
	{
		var allGuests = Scene.GetAllComponents<Guest>();
		if ( !allGuests.Any() ) return 0f;

		float totalHappiness = 0f;

		foreach ( var guest in allGuests )
		{
			var needs = guest.GetComponent<NeedSystem>();
			totalHappiness += needs.Happiness;
		}

		return totalHappiness / allGuests.Count();
	}

	/// <summary>
	/// Called when a new day starts -- deduct daily costs, pay staff wages, reset daily income.
	/// </summary>
	void ITimeOfDayEvents.OnNewDay()
	{
		if ( Stats.Get( "park.max_daily_income" ) < DailyIncome )
		{
			Stats.Set( "park.max_daily_income", DailyIncome );
		}

		DailyIncome = 0;

		var costs = DailyOperationalCosts;
		var wages = 0;
		foreach ( var staff in StaffManager.Instance.StaffList )
		{
			wages += staff.Definition.Wages;
		}

		TakeMoney( costs, "Daily Operational Cost" );
		TakeMoney( wages, "Staff Wages" );

		costs += wages;
		NotificationPanel.Instance?.Broadcast( $"You have been charged {GameUtils.Currency}{costs} for daily operational costs.", "paid" );
	}
}

/// <summary>
/// Defines an object's material for placement sound effects. This shouldn't exist really.
/// </summary>
public enum ObjectMaterial
{
	Default,
	Wood,
	Metal,
	Natural
}