using Fitness_core_BE.Exceptions; using Fitness_core_BE.Models; using Fitness_core_BE.Models.Enums; using System.Globalization; using System.Text.Json; namespace Fitness_core_BE.Helpers { public static class CustomerProgressHelper { public static CustomerTarget BuildCustomerTarget( Guid cusId, decimal currentWeight, decimal currentHeight, decimal? targetWeight, string? goal, decimal timePeriodMonths, ProgressPrediction predictions, CustomerTarget? latestTarget, DateTime createdAt) { var timePeriod = timePeriodMonths.ToString("0.##", CultureInfo.InvariantCulture); return new CustomerTarget { FKCusId = cusId, CurrentWeight = currentWeight, CurrentHeight = currentHeight, TargetWeight = targetWeight, Goal = goal, TimePeriod = timePeriod, PredictedNutritionsJson = JsonSerializer.Serialize(predictions.PredictedNutritions), PredictedWeightsJson = JsonSerializer.Serialize(predictions.PredictedWeights), IsChanged = latestTarget != null && (latestTarget.CurrentWeight != currentWeight || latestTarget.CurrentHeight != currentHeight || latestTarget.TargetWeight != targetWeight || latestTarget.Goal != goal || latestTarget.TimePeriod != timePeriod), CreatedAt = createdAt, IsActive = true }; } public static object BuildCustomerProgressResponse(CustomerTarget customerTarget) { return new { ProgressId = customerTarget.CustomerTargetId, customerTarget.CustomerTargetId, customerTarget.CurrentWeight, customerTarget.CurrentHeight, customerTarget.TargetWeight, customerTarget.Goal, customerTarget.TimePeriod, customerTarget.CreatedAt, customerTarget.FKCusId, customerTarget.IsActive, TargetIsChanged = customerTarget.IsChanged, TargetCreatedAt = customerTarget.CreatedAt, PredictedNutritions = DeserializePayload.DeserializeJson>(customerTarget.PredictedNutritionsJson), PredictedWeights = DeserializePayload.DeserializeJson>(customerTarget.PredictedWeightsJson) }; } public static ProgressPrediction BuildProgressPredictions( decimal weight, decimal targetWeight, decimal height, string? goal, decimal timePeriodMonths, int age, string? gender, string? activityLevel) { ValidatePredictionInput(weight, targetWeight, height, timePeriodMonths, age); // Normalize user choices before using them in scientific formulas. var goalType = GetGoalType(goal); var sex = GetSex(gender); var activity = GetActivityLevel(activityLevel); ValidateGoalDirection(goalType, weight, targetWeight); // Convert the target period into weeks and calculate the requested weekly pace. var weeksAvailable = (double)timePeriodMonths * 4.33; var totalChange = goalType == ProgressGoalType.Maintain ? 0.0 : Math.Abs((double)(weight - targetWeight)); var requiredWeeklyChange = weeksAvailable > 0 ? totalChange / weeksAvailable : 0.0; // BMR uses Mifflin-St Jeor; TDEE applies the selected activity multiplier. var bmr = CalculateBmr(weight, height, age, sex); var tdee = bmr * activity.Multiplier; var dailyCalorieAdjustment = GetDailyCalorieAdjustment(goalType, requiredWeeklyChange); var direction = GetDirection(goalType); // Apply calorie deficit, surplus, or maintenance based on the goal. var targetCalories = goalType switch { ProgressGoalType.Loss => tdee - dailyCalorieAdjustment, ProgressGoalType.Gain => tdee + dailyCalorieAdjustment, _ => tdee }; // Estimate weekly weight change using the 7700 kcal per kg approximation. var predictedWeeklyChange = dailyCalorieAdjustment <= 0 ? 0.0 : (dailyCalorieAdjustment * 7.0) / 7700.0; // Protein depends on goal; fat is 25% of calories; carbs use remaining calories. var proteinPerKg = GetProteinFactor(goalType, goal); var protein = Math.Round((double)weight * proteinPerKg, 2); var fat = Math.Round((targetCalories * 0.25) / 9.0, 2); var remainingCalories = targetCalories - (protein * 4.0 + fat * 9.0); var carbs = Math.Round(Math.Max(remainingCalories / 4.0, 0.0), 2); var bmi = Math.Round(CalculateBmi(weight, height), 2); var difficulty = DetermineDifficulty(requiredWeeklyChange); // Build a week-by-week projection and prevent values from passing the target weight. var totalWeeks = Math.Max(1, (int)Math.Ceiling(weeksAvailable)); var predictedWeights = new Dictionary(); for (var week = 0; week <= totalWeeks; week++) { var predictedWeight = (double)weight + (direction * predictedWeeklyChange * week); predictedWeight = KeepPredictionInsideTarget(goalType, predictedWeight, (double)targetWeight); predictedWeights[$"w{week + 1}"] = (decimal)Math.Round(predictedWeight, 1); } var signedCalorieAdjustment = direction * dailyCalorieAdjustment; // Keep output as strings because this JSON is stored and returned directly to clients. var predictedNutritions = new Dictionary { ["calories"] = $"{FormatNumber(targetCalories)} kcal/day", ["targetCalories"] = $"{FormatNumber(targetCalories)} kcal/day", ["bmr"] = $"{FormatNumber(bmr)} kcal/day", ["tdee"] = $"{FormatNumber(tdee)} kcal/day", ["protein"] = $"{FormatNumber(protein)}g", ["fat"] = $"{FormatNumber(fat)}g", ["carbs"] = $"{FormatNumber(carbs)}g", ["bmi"] = FormatNumber(bmi), ["activityLevel"] = activity.Label, ["activityMultiplier"] = FormatNumber(activity.Multiplier), ["dailyCalorieChange"] = $"{FormatSignedNumber(signedCalorieAdjustment)} kcal/day", ["requiredWeeklyWeightChange"] = $"{FormatNumber(requiredWeeklyChange)} kg/week", ["predictedWeeklyWeightChange"] = $"{FormatNumber(predictedWeeklyChange)} kg/week", ["difficulty"] = difficulty, ["intensity"] = difficulty }; return new ProgressPrediction { PredictedNutritions = predictedNutritions, PredictedWeights = predictedWeights, Intensity = difficulty }; } // Re-projects just the weight trajectory (not the nutrition targets, which need // activityLevel — a one-time input never persisted on CustomerTarget) from the // customer's latest logged weight to their existing target, over however much of // the original plan window is left. Called whenever a weight update changes // CurrentWeight, so the "goal" line reflects real progress instead of staying // fixed to the weight recorded at onboarding. public static Dictionary RebuildPredictedWeights( decimal currentWeight, decimal targetWeight, string? goal, double remainingWeeks) { var goalType = GetGoalType(goal); var direction = GetDirection(goalType); var totalChange = goalType == ProgressGoalType.Maintain ? 0.0 : Math.Abs((double)(currentWeight - targetWeight)); var weeklyChange = remainingWeeks > 0 ? totalChange / remainingWeeks : 0.0; var totalWeeks = Math.Max(1, (int)Math.Ceiling(remainingWeeks)); var predictedWeights = new Dictionary(); for (var week = 0; week <= totalWeeks; week++) { var predictedWeight = (double)currentWeight + (direction * weeklyChange * week); predictedWeight = KeepPredictionInsideTarget(goalType, predictedWeight, (double)targetWeight); predictedWeights[$"w{week + 1}"] = (decimal)Math.Round(predictedWeight, 1); } return predictedWeights; } public static int GetProgressPredictionAge(Dictionary data, Customer customer, DateTime asOf) { var age = DataExtractor.GetNullableInt(data, "age"); if (age.HasValue) { if (age.Value <= 0) throw new BadRequestException("Field 'age' must be greater than zero."); return age.Value; } if (!customer.BoD.HasValue) throw new BadRequestException("Field 'age' is required when customer date of birth is not available."); var calculatedAge = CalculateAge(customer.BoD.Value, asOf); if (calculatedAge <= 0) throw new BadRequestException( $"The date of birth on this profile ({customer.BoD.Value:yyyy-MM-dd}) is today or in " + "the future, so an age can't be worked out. Update the date of birth on the profile, " + "or send an explicit 'age' with this request."); return calculatedAge; } public static string GetProgressPredictionGender(Dictionary data, Customer customer) { var gender = DataExtractor.GetOptionalStringByKeys(data, "gender", "Gender") ?? customer.Gender; if (!string.IsNullOrWhiteSpace(gender)) return gender; throw new BadRequestException("Field 'gender' is required when customer gender is not available."); } public static int CalculateAge(DateOnly birthDate, DateTime asOf) { var today = DateOnly.FromDateTime(asOf); var age = today.Year - birthDate.Year; if (birthDate > today.AddYears(-age)) age--; return age; } public static bool IsMaintainGoal(string goal) { var normalizedGoal = NormalizeText(goal); return normalizedGoal is "maintain" or "maintain weight"; } private static void ValidatePredictionInput( decimal weight, decimal targetWeight, decimal height, decimal timePeriodMonths, int age) { if (weight <= 0) throw new ArgumentException("Field 'currentWeight' must be greater than zero."); if (targetWeight <= 0) throw new ArgumentException("Field 'targetWeight' must be greater than zero."); if (height <= 0) throw new ArgumentException("Field 'currentHeight' must be greater than zero."); if (timePeriodMonths <= 0) throw new ArgumentException("Field 'timePeriod' must be greater than zero."); if (age <= 0) throw new ArgumentException("Field 'age' must be greater than zero."); } private static ProgressGoalType GetGoalType(string? goal) { var normalizedGoal = NormalizeText(goal); return normalizedGoal switch { "lose weight" => ProgressGoalType.Loss, "gain muscle" => ProgressGoalType.Gain, "body recomposition" => ProgressGoalType.Maintain, "strength" => ProgressGoalType.Strength, "endurance" => ProgressGoalType.Endurance, "athletic performance" => ProgressGoalType.AthleticPerformance, "stay fit" => ProgressGoalType.StayFit, "flexibility" => ProgressGoalType.StayFit, "beginner" => ProgressGoalType.Beginner, _ => throw new ArgumentException("Field 'goal' must be one of these") }; } private static Sex GetSex(string? gender) { var normalizedGender = NormalizeText(gender); return normalizedGender switch { "male" or "m" => Sex.Male, "female" or "f" => Sex.Female, _ => throw new ArgumentException("Field 'gender' must be either 'Male' or 'Female' for BMR calculation.") }; } private static ActivityLevelInfo GetActivityLevel(string? activityLevel) { var normalizedActivityLevel = NormalizeText(activityLevel); return normalizedActivityLevel switch { "low" => new ActivityLevelInfo("Lightly Active", 1.375), "moderate" => new ActivityLevelInfo("Moderately Active", 1.55), "high" => new ActivityLevelInfo("Very Active", 1.725), _ => throw new ArgumentException("Field 'activityLevel' must be one of: Lightly Active, Moderately Active, Very Active") }; } private static void ValidateGoalDirection(ProgressGoalType goalType, decimal weight, decimal targetWeight) { if (goalType == ProgressGoalType.Loss && targetWeight > weight) throw new ArgumentException("Field 'targetWeight' must be less than or equal to current weight for weight loss or fat loss goals."); if (goalType == ProgressGoalType.Gain && targetWeight < weight) throw new ArgumentException("Field 'targetWeight' must be greater than or equal to current weight for weight gain or muscle gain goals."); } private static double CalculateBmr(decimal weight, decimal height, int age, Sex sex) { var baseBmr = (10.0 * (double)weight) + (6.25 * (double)height) - (5.0 * age); return sex == Sex.Male ? baseBmr + 5.0 : baseBmr - 161.0; } private static double CalculateBmi(decimal weight, decimal height) { var heightMeters = (double)height / 100.0; return (double)weight / (heightMeters * heightMeters); } private static double GetDailyCalorieAdjustment(ProgressGoalType goalType, double requiredWeeklyChange) { var requestedDailyChange = (requiredWeeklyChange * 7700.0) / 7.0; return goalType switch { ProgressGoalType.Loss => Math.Min(requestedDailyChange, 700.0), ProgressGoalType.Gain => Math.Min(requestedDailyChange, 400.0), _ => 0.0 }; } private static double GetProteinFactor(ProgressGoalType goalType, string? goal) { var normalizedGoal = NormalizeText(goal); if (normalizedGoal == "muscle gain") return 2.0; return goalType switch { ProgressGoalType.Loss => 2.0, ProgressGoalType.Gain => 1.8, _ => 1.6 }; } private static double GetDirection(ProgressGoalType goalType) { return goalType switch { ProgressGoalType.Loss => -1.0, ProgressGoalType.Gain => 1.0, _ => 0.0 }; } private static double KeepPredictionInsideTarget(ProgressGoalType goalType, double predictedWeight, double targetWeight) { return goalType switch { ProgressGoalType.Loss => Math.Max(predictedWeight, targetWeight), ProgressGoalType.Gain => Math.Min(predictedWeight, targetWeight), _ => predictedWeight }; } private static string DetermineDifficulty(double requiredWeeklyChange) { return requiredWeeklyChange switch { <= 0.5 => "Easy", <= 0.75 => "Moderate", <= 1.0 => "Hard", _ => "Extreme / Unsafe" }; } private static string NormalizeText(string? value) { var normalized = (value ?? string.Empty) .Trim() .ToLowerInvariant() .Replace("-", " ") .Replace("_", " "); return string.Join(' ', normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries)); } private static string FormatNumber(double value) { return value.ToString("0.##", CultureInfo.InvariantCulture); } private static string FormatSignedNumber(double value) { var sign = value > 0 ? "+" : string.Empty; return sign + FormatNumber(value); } private readonly record struct ActivityLevelInfo(string Label, double Multiplier); public static string GetPredictedNutritionsJson(Dictionary data) { var nutritions = data.TryGetValue("predictedNutritions", out var element) ? BuildNutritionDictionary(element) : BuildNutritionDictionary(data); return JsonSerializer.Serialize(nutritions); } private static Dictionary BuildNutritionDictionary(Dictionary data) { return new Dictionary { ["calories"] = GetRequiredNutritionValueByKeys(data, "calories"), ["protein"] = GetRequiredNutritionValue(data, "protein"), ["fat"] = GetRequiredNutritionValue(data, "fat"), ["carbs"] = GetRequiredNutritionValue(data, "carbs"), ["intensity"] = GetRequiredNutritionValue(data, "intensity") }; } private static Dictionary BuildNutritionDictionary(JsonElement element) { if (element.ValueKind != JsonValueKind.Object) throw new BadRequestException("Field 'predictedNutritions' must be a JSON object."); return new Dictionary { ["calories"] = GetRequiredNutritionValueByKeys(element, "calories"), ["protein"] = GetRequiredNutritionValue(element, "protein"), ["fat"] = GetRequiredNutritionValue(element, "fat"), ["carbs"] = GetRequiredNutritionValue(element, "carbs"), ["intensity"] = GetRequiredNutritionValue(element, "intensity") }; } private static string GetRequiredNutritionValueByKeys(Dictionary data, params string[] keys) { foreach (var key in keys) { if (data.TryGetValue(key, out var element)) return ReadNutritionValue(element, key); } throw new BadRequestException($"Missing required field '{keys[0]}'."); } private static string GetRequiredNutritionValueByKeys(JsonElement source, params string[] keys) { foreach (var key in keys) { if (source.TryGetProperty(key, out var element)) return ReadNutritionValue(element, key); } throw new BadRequestException($"Missing required field '{keys[0]}'."); } private static string GetRequiredNutritionValue(Dictionary data, string key) { if (!data.TryGetValue(key, out var element)) throw new BadRequestException($"Missing required field '{key}'."); return ReadNutritionValue(element, key); } private static string GetRequiredNutritionValue(JsonElement source, string key) { if (!source.TryGetProperty(key, out var element)) throw new BadRequestException($"Missing required field '{key}'."); return ReadNutritionValue(element, key); } private static string ReadNutritionValue(JsonElement element, string key) { if (element.ValueKind == JsonValueKind.String) { var value = element.GetString(); if (!string.IsNullOrWhiteSpace(value)) return value; } if (element.ValueKind == JsonValueKind.Number) return element.GetRawText(); throw new BadRequestException($"Field '{key}' must be a non-empty string or number."); } } public sealed class ProgressPrediction { public Dictionary PredictedNutritions { get; set; } = new(); public Dictionary PredictedWeights { get; set; } = new(); public string Intensity { get; set; } = "Moderate"; } }