using System.Text.Json; using MealPlan.Api.Data; using MealPlan.Api.DTOs.Recipe; using MealPlan.Api.Models; using MealPlan.Api.Services.AI; using MealPlan.Api.Services.Generators; using Microsoft.EntityFrameworkCore; namespace MealPlan.Api.Services; public interface IRecipeCalorieRecalculationService { Task RecalculateAsync( int recipeId, RecalculateRecipeCaloriesRequestDto request, CancellationToken ct = default); } public class RecipeCalorieRecalculationService : IRecipeCalorieRecalculationService { private readonly AppDbContext _db; private readonly IOpenAiChatService _openAi; private readonly IRecipeManagementService _recipeManagement; public RecipeCalorieRecalculationService( AppDbContext db, IOpenAiChatService openAi, IRecipeManagementService recipeManagement) { _db = db; _openAi = openAi; _recipeManagement = recipeManagement; } public async Task RecalculateAsync( int recipeId, RecalculateRecipeCaloriesRequestDto request, CancellationToken ct = default) { if (request.TargetCalories is < 50 or > 10000) { throw new InvalidOperationException("Target calories must be between 50 and 10000."); } var recipe = await _db.Recipes.AsNoTracking() .Include(r => r.Ingredients) .Include(r => r.Steps) .FirstOrDefaultAsync(r => r.Id == recipeId && r.IsActive, ct); if (recipe is null) return null; var tolerance = request.CalorieToleranceKcal > 0 ? request.CalorieToleranceKcal : 10; var workingIngredients = recipe.Ingredients .OrderBy(i => i.SortOrder) .Select(CloneIngredient) .ToList(); var methods = new List(); var currentTotals = await ComputeTotalsAsync(workingIngredients, ct); var scaleFactor = RecipeMacroCalculator.ResolveScaleFactor( currentTotals?.Calories ?? recipe.Calories ?? 0, request.TargetCalories, tolerance); if (scaleFactor != 1m) { RecipeMacroCalculator.ScaleIngredientAmounts( workingIngredients, scaleFactor, tolerance, request.TargetCalories, currentTotals); methods.Add("catalog"); } currentTotals = await ComputeTotalsAsync(workingIngredients, ct); var needsAi = _openAi.IsConfigured && (currentTotals is null || Math.Abs(currentTotals.Value.Calories - request.TargetCalories) > tolerance || RecipeMacroCalculator.CountUnlinkedMacroIngredients(workingIngredients) > 0); if (needsAi) { await ApplyAiAdjustedAmountsAsync(recipe, workingIngredients, request.TargetCalories, ct); methods.Add("ai"); currentTotals = await ComputeTotalsAsync(workingIngredients, ct); } if (currentTotals is null && recipe.Calories is > 0) { var fallbackScale = RecipeMacroCalculator.ResolveScaleFactor( recipe.Calories.Value, request.TargetCalories, tolerance); if (fallbackScale != 1m) { foreach (var ing in workingIngredients) { if (ing.AmountGrams is not > 0 || RecipeMacroCalculator.IsSpiceUnit(ing.Unit)) continue; ing.AmountGrams = Math.Round(ing.AmountGrams.Value * fallbackScale, 1); } if (!methods.Contains("catalog")) methods.Add("catalog"); } } currentTotals = await ComputeTotalsAsync(workingIngredients, ct); var ingredientDtos = MapIngredients(workingIngredients); var unlinked = RecipeMacroCalculator.CountUnlinkedMacroIngredients(workingIngredients); RecipeDetailDto? savedRecipe = null; if (request.Apply) { var updateDto = new CreateRecipeDto { Name = recipe.Name, MealCategory = recipe.MealCategory, Calories = currentTotals?.Calories ?? request.TargetCalories, Protein = currentTotals?.Protein, Fat = currentTotals?.Fat, Carbs = currentTotals?.Carbs, PrepTimeMinutes = recipe.PrepTimeMinutes, Ingredients = workingIngredients.Select((i, index) => new CreateIngredientDto { Name = i.Name, AmountGrams = i.AmountGrams, Unit = i.Unit, SortOrder = index, CatalogItemId = i.CatalogItemId, }).ToList(), Steps = recipe.Steps .OrderBy(s => s.StepNumber) .Select(s => new CreateRecipeStepDto { StepNumber = s.StepNumber, Description = s.Description, }).ToList(), }; savedRecipe = await _recipeManagement.UpdateRecipeAsync(recipeId, updateDto, ct); if (savedRecipe is null) return null; } return new RecalculateRecipeCaloriesResultDto { RecipeId = recipeId, PreviousCalories = recipe.Calories, TargetCalories = request.TargetCalories, NewCalories = currentTotals?.Calories ?? savedRecipe?.Calories, NewProtein = currentTotals?.Protein ?? savedRecipe?.Protein, NewFat = currentTotals?.Fat ?? savedRecipe?.Fat, NewCarbs = currentTotals?.Carbs ?? savedRecipe?.Carbs, Applied = request.Apply && savedRecipe is not null, Method = methods.Count == 0 ? "none" : string.Join('+', methods), UnlinkedIngredientCount = unlinked, Ingredients = savedRecipe?.Ingredients ?? ingredientDtos, Recipe = savedRecipe, }; } private async Task ApplyAiAdjustedAmountsAsync( Recipe source, IList ingredients, int targetCalories, CancellationToken ct) { var system = """ You adjust recipe ingredient amounts to match a calorie target for a meal planning app. Respond ONLY JSON: {"ingredients":[{"name":"exact ingredient name","amountGrams":100,"unit":null}]} Rules: - Keep every ingredient from the input list (same names). - Change amountGrams for macro ingredients so the recipe totals about targetCalories kcal. - Use realistic gram amounts. Round to one decimal. - Spices with unit "do smaku" / "to taste" keep null amountGrams. - Do not add or remove ingredients. """; var user = JsonSerializer.Serialize(new { recipeName = source.Name, targetCalories, currentCalories = source.Calories, ingredients = ingredients.Select(i => new { i.Name, i.AmountGrams, i.Unit, i.CatalogItemId, }), }); var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct); using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("ingredients", out var ingredientsEl) || ingredientsEl.ValueKind != JsonValueKind.Array) { throw new InvalidOperationException("AI did not return adjusted ingredients. Try again."); } var byName = ingredients.ToDictionary(i => i.Name.Trim(), StringComparer.OrdinalIgnoreCase); foreach (var item in ingredientsEl.EnumerateArray()) { var name = item.TryGetProperty("name", out var nameEl) ? AiRecipeJsonParser.GetString(nameEl) : null; if (string.IsNullOrWhiteSpace(name) || !byName.TryGetValue(name.Trim(), out var target)) continue; if (item.TryGetProperty("amountGrams", out var gramsEl)) { target.AmountGrams = AiRecipeJsonParser.GetNullableDecimal(gramsEl); } if (item.TryGetProperty("unit", out var unitEl) && unitEl.ValueKind != JsonValueKind.Null) { target.Unit = AiRecipeJsonParser.GetString(unitEl); } } } private async Task ComputeTotalsAsync(IEnumerable ingredients, CancellationToken ct) { return await RecipeMacroCalculator.ComputeFromCatalogAsync( _db, ingredients.Select(i => (i.AmountGrams, i.Unit, i.CatalogItemId)), ct); } private static Ingredient CloneIngredient(Ingredient source) => new() { Id = source.Id, Name = source.Name, AmountGrams = source.AmountGrams, Unit = source.Unit, SortOrder = source.SortOrder, CatalogItemId = source.CatalogItemId, }; private static List MapIngredients(IEnumerable ingredients) => ingredients .OrderBy(i => i.SortOrder) .Select(i => new IngredientDto { Id = i.Id, Name = i.Name, AmountGrams = i.AmountGrams, Unit = i.Unit, SortOrder = i.SortOrder, CatalogItemId = i.CatalogItemId, }).ToList(); }