using System.Text.Json;
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.Diet;
using MealPlan.Api.DTOs.Generators;
using MealPlan.Api.Models;
using MealPlan.Api.Services.AI;
using Microsoft.EntityFrameworkCore;

namespace MealPlan.Api.Services.Generators;

public interface IDietGeneratorService
{
    Task<DietUserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct = default);
    Task<DietUserProfileDto> UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default);
    Task<DietGeneratorSessionDto> CreateSessionAsync(Guid userId, CreateDietGeneratorSessionDto dto, CancellationToken ct = default);
    Task<DietGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default);
    Task<DietGeneratorSessionDto?> ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default);
    Task<DietGeneratorSessionDto?> AcceptMacrosAsync(Guid userId, int sessionId, AcceptMacrosDto dto, CancellationToken ct = default);
    Task<DietGeneratorSessionDto?> GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default);
    Task<DietGeneratorSessionDto?> RegenerateMealAsync(Guid userId, int sessionId, RegenerateDietMealDto dto, CancellationToken ct = default);
    Task<DietGeneratorSessionDto?> UpdateDraftMealAsync(Guid userId, int sessionId, int mealId, UpdateDraftMealDto dto, CancellationToken ct = default);
    Task<CommitDietPlanResultDto?> CommitAsync(Guid userId, int sessionId, CancellationToken ct = default);
}

public class DietGeneratorService : IDietGeneratorService
{
    private readonly AppDbContext _db;
    private readonly IOpenAiChatService _openAi;

    public DietGeneratorService(AppDbContext db, IOpenAiChatService openAi)
    {
        _db = db;
        _openAi = openAi;
    }

    public async Task<DietUserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct = default)
    {
        var profile = await _db.DietUserProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, ct);
        return profile is null ? null : MapProfile(profile);
    }

    public async Task<DietUserProfileDto> UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default)
    {
        var profile = await _db.DietUserProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct);
        if (profile is null)
        {
            profile = new DietUserProfile { UserId = userId, CreatedAt = DateTime.UtcNow };
            _db.DietUserProfiles.Add(profile);
        }

        profile.HeightCm = dto.HeightCm;
        profile.WeightKg = dto.WeightKg;
        profile.Age = dto.Age;
        profile.Sex = dto.Sex;
        profile.ActivityLevel = dto.ActivityLevel;
        profile.Goal = dto.Goal;
        profile.AllergiesNotes = dto.AllergiesNotes;
        profile.DislikedFoods = dto.DislikedFoods;
        profile.MealsPerDayMask = dto.MealsPerDayMask;
        profile.UpdatedAt = DateTime.UtcNow;

        await _db.SaveChangesAsync(ct);
        return MapProfile(profile);
    }

    public async Task<DietGeneratorSessionDto> CreateSessionAsync(
        Guid userId,
        CreateDietGeneratorSessionDto dto,
        CancellationToken ct = default)
    {
        var profile = await _db.DietUserProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, ct)
            ?? throw new InvalidOperationException("Save your diet profile before starting a generator session.");

        var session = new DietGeneratorSession
        {
            UserId = userId,
            Status = DietGeneratorStatuses.MacrosPending,
            PlanStartDate = dto.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow),
            PlanDayCount = dto.PlanDayCount,
            CalorieToleranceKcal = dto.CalorieToleranceKcal,
            CreatedAt = DateTime.UtcNow,
        };

        _db.DietGeneratorSessions.Add(session);
        await _db.SaveChangesAsync(ct);

        var calculated = TdeeCalculator.Calculate(
            profile.HeightCm, profile.WeightKg, profile.Age, profile.Sex, profile.ActivityLevel, profile.Goal);

        session.ProposedCalories = calculated.Calories;
        session.ProposedProteinG = calculated.ProteinG;
        session.ProposedFatG = calculated.FatG;
        session.ProposedCarbsG = calculated.CarbsG;
        session.MacroRationale = calculated.Rationale;
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);

        return await MapSessionAsync(session.Id, ct) ?? throw new InvalidOperationException("Session not found.");
    }

    public async Task<DietGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default)
    {
        await BackfillMissingDraftIngredientsAsync(userId, sessionId, ct);
        return await MapSessionAsync(sessionId, userId, ct);
    }

    public async Task<DietGeneratorSessionDto?> ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default)
    {
        var session = await GetEditableSessionAsync(userId, sessionId, ct);
        if (session is null) return null;

        var profile = await _db.DietUserProfiles.AsNoTracking().FirstAsync(p => p.UserId == userId, ct);
        var calculated = TdeeCalculator.Calculate(
            profile.HeightCm, profile.WeightKg, profile.Age, profile.Sex, profile.ActivityLevel, profile.Goal);

        if (_openAi.IsConfigured)
        {
            try
            {
                var system = """
                    You are a sports nutrition assistant. Respond ONLY with JSON:
                    {"calories":number,"proteinG":number,"fatG":number,"carbsG":number,"rationale":"string"}
                    Keep calories within 10% of the provided baseline. Use realistic macro splits.
                    """;
                var user = JsonSerializer.Serialize(new
                {
                    baseline = calculated,
                    profile = new
                    {
                        profile.HeightCm,
                        profile.WeightKg,
                        profile.Age,
                        profile.Sex,
                        profile.ActivityLevel,
                        profile.Goal,
                        profile.AllergiesNotes,
                    },
                });

                var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Macros, ct);
                using var doc = JsonDocument.Parse(json);
                var root = doc.RootElement;
                session.ProposedCalories = root.TryGetProperty("calories", out var c)
                    ? c.GetInt32()
                    : calculated.Calories;
                session.ProposedProteinG = root.TryGetProperty("proteinG", out var p)
                    ? p.GetDecimal()
                    : calculated.ProteinG;
                session.ProposedFatG = root.TryGetProperty("fatG", out var f)
                    ? f.GetDecimal()
                    : calculated.FatG;
                session.ProposedCarbsG = root.TryGetProperty("carbsG", out var cb)
                    ? cb.GetDecimal()
                    : calculated.CarbsG;
                session.MacroRationale = root.TryGetProperty("rationale", out var r)
                    ? r.GetString()
                    : calculated.Rationale;
            }
            catch
            {
                ApplyCalculatedMacros(session, calculated);
            }
        }
        else
        {
            ApplyCalculatedMacros(session, calculated);
        }

        session.Status = DietGeneratorStatuses.MacrosPending;
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);
        return await MapSessionAsync(sessionId, ct);
    }

    public async Task<DietGeneratorSessionDto?> AcceptMacrosAsync(
        Guid userId,
        int sessionId,
        AcceptMacrosDto dto,
        CancellationToken ct = default)
    {
        var session = await GetEditableSessionAsync(userId, sessionId, ct);
        if (session is null) return null;

        if (dto.Calories.HasValue) session.ProposedCalories = dto.Calories;
        if (dto.ProteinG.HasValue) session.ProposedProteinG = dto.ProteinG;
        if (dto.FatG.HasValue) session.ProposedFatG = dto.FatG;
        if (dto.CarbsG.HasValue) session.ProposedCarbsG = dto.CarbsG;

        session.Status = DietGeneratorStatuses.MacrosAccepted;
        session.MacrosAcceptedAt = DateTime.UtcNow;
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);
        return await MapSessionAsync(sessionId, ct);
    }

    public async Task<DietGeneratorSessionDto?> GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default)
    {
        var session = await GetEditableSessionAsync(userId, sessionId, ct);
        if (session is null || !session.ProposedCalories.HasValue) return null;

        var profile = await _db.DietUserProfiles.AsNoTracking().FirstAsync(p => p.UserId == userId, ct);
        var pool = await LoadRecipePoolAsync(userId, ct);
        if (pool.Count == 0)
        {
            throw new InvalidOperationException("No recipes with complete macros are available for planning.");
        }

        var mealCategories = MealPlanOptimizer.MealCategoriesFromMask(profile.MealsPerDayMask);
        var requiredUnique = MealPlanOptimizer.RequiredUniqueRecipes(session.PlanDayCount, mealCategories);
        if (pool.Count < requiredUnique)
        {
            throw new InvalidOperationException(
                $"Need at least {requiredUnique} unique recipes for a {session.PlanDayCount}-day plan " +
                $"({mealCategories.Count} meals/day), but only {pool.Count} are available.");
        }

        var start = session.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
        var usedRecipes = new HashSet<int>();
        var allSlots = new List<DraftMealSlot>();

        if (_openAi.IsConfigured)
        {
            try
            {
                allSlots = await GeneratePlanWithAiAsync(session, pool, mealCategories, start, usedRecipes, ct);
            }
            catch
            {
                allSlots = BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
            }
        }
        else
        {
            allSlots = BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
        }

        MealPlanOptimizer.EnsureWeeklyUniqueRecipes(
            allSlots,
            pool,
            mealCategories,
            session.ProposedCalories!.Value,
            session.CalorieToleranceKcal);

        _db.DietGeneratorDraftMeals.RemoveRange(
            await _db.DietGeneratorDraftMeals.Where(m => m.SessionId == sessionId).ToListAsync(ct));

        var recipes = pool.ToDictionary(r => r.Id);
        foreach (var slot in allSlots)
        {
            if (!recipes.TryGetValue(slot.RecipeId, out var recipe)) continue;
            _db.DietGeneratorDraftMeals.Add(new DietGeneratorDraftMeal
            {
                SessionId = sessionId,
                PlanDate = slot.PlanDate,
                MealCategory = slot.MealCategory,
                RecipeId = slot.RecipeId,
                Portions = slot.Portions,
                Calories = slot.TotalCalories,
                ProteinG = Math.Round(slot.TotalProtein, 2),
                FatG = Math.Round(slot.TotalFat, 2),
                CarbsG = Math.Round(slot.TotalCarbs, 2),
                GenerationVersion = 1,
            });
        }

        session.Status = DietGeneratorStatuses.PlanDraft;
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);

        var draftMeals = await _db.DietGeneratorDraftMeals
            .Where(m => m.SessionId == sessionId)
            .ToListAsync(ct);
        foreach (var draftMeal in draftMeals)
        {
            await SyncDraftMealIngredientsAsync(draftMeal.Id, draftMeal.RecipeId, draftMeal.Portions, ct);
        }

        await _db.SaveChangesAsync(ct);
        return await MapSessionAsync(sessionId, ct);
    }

    public async Task<DietGeneratorSessionDto?> RegenerateMealAsync(
        Guid userId,
        int sessionId,
        RegenerateDietMealDto dto,
        CancellationToken ct = default)
    {
        var session = await GetEditableSessionAsync(userId, sessionId, ct);
        if (session is null || !session.ProposedCalories.HasValue) return null;

        var draftMeals = await _db.DietGeneratorDraftMeals
            .Where(m => m.SessionId == sessionId)
            .ToListAsync(ct);

        var dayMeals = draftMeals.Where(m => m.PlanDate == dto.PlanDate).ToList();
        var target = session.ProposedCalories.Value;
        var lockedCalories = dayMeals.Where(m => m.IsLocked && m.PlanDate == dto.PlanDate).Sum(m => m.Calories ?? 0);
        var remaining = Math.Max(200, target - lockedCalories);
        var unlockedSlots = dayMeals.Count(m => !m.IsLocked);
        var slotTarget = unlockedSlots > 0 ? remaining / unlockedSlots : remaining;

        var pool = await LoadRecipePoolAsync(userId, ct);

        var existing = dayMeals.FirstOrDefault(m => m.MealCategory == dto.MealCategory);
        if (existing is null) return null;
        if (existing.IsLocked) return await MapSessionAsync(sessionId, ct);

        var excluded = draftMeals
            .Where(m => m.Id != existing.Id)
            .Select(m => m.RecipeId)
            .ToHashSet();
        excluded.Add(existing.RecipeId);

        if (!MealPlanOptimizer.TryPickGreedyMeal(pool, dto.MealCategory, slotTarget, excluded, out var picked))
        {
            throw new InvalidOperationException(
                "No other recipes are available for this meal. All suitable options are already used in the plan.");
        }

        var recipe = pool.First(r => r.Id == picked.RecipeId);

        existing.RecipeId = picked.RecipeId;
        existing.Portions = picked.Portions;
        existing.Calories = picked.TotalCalories;
        existing.ProteinG = Math.Round(picked.TotalProtein, 2);
        existing.FatG = Math.Round(picked.TotalFat, 2);
        existing.CarbsG = Math.Round(picked.TotalCarbs, 2);
        existing.GenerationVersion++;

        var slots = dayMeals.Select(m => new DraftMealSlot
        {
            PlanDate = m.PlanDate,
            MealCategory = m.MealCategory,
            RecipeId = m.RecipeId,
            Portions = m.Portions,
            CaloriesPerPortion = recipe.Calories,
            FatPerPortion = recipe.Fat,
            ProteinPerPortion = recipe.Protein,
            CarbsPerPortion = recipe.Carbs,
            IsLocked = m.IsLocked,
        }).ToList();

        foreach (var slot in slots)
        {
            var r = pool.First(x => x.Id == slot.RecipeId);
            slot.CaloriesPerPortion = r.Calories;
            slot.ProteinPerPortion = r.Protein;
            slot.FatPerPortion = r.Fat;
            slot.CarbsPerPortion = r.Carbs;
        }

        MealPlanOptimizer.BalanceDay(slots, target, session.CalorieToleranceKcal);
        foreach (var slot in slots)
        {
            var entity = dayMeals.First(m => m.MealCategory == slot.MealCategory);
            var r = pool.First(x => x.Id == slot.RecipeId);
            entity.Portions = slot.Portions;
            entity.Calories = slot.TotalCalories;
            entity.ProteinG = Math.Round(slot.TotalProtein, 2);
            entity.FatG = Math.Round(slot.TotalFat, 2);
            entity.CarbsG = Math.Round(slot.TotalCarbs, 2);
        }

        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);
        await SyncDraftMealIngredientsAsync(existing.Id, existing.RecipeId, existing.Portions, ct);
        await _db.SaveChangesAsync(ct);
        return await MapSessionAsync(sessionId, ct);
    }

    public async Task<DietGeneratorSessionDto?> UpdateDraftMealAsync(
        Guid userId,
        int sessionId,
        int mealId,
        UpdateDraftMealDto dto,
        CancellationToken ct = default)
    {
        var session = await GetEditableSessionAsync(userId, sessionId, ct);
        if (session is null) return null;

        var meal = await _db.DietGeneratorDraftMeals
            .FirstOrDefaultAsync(m => m.Id == mealId && m.SessionId == sessionId, ct);
        if (meal is null) return null;

        if (dto.IsLocked.HasValue) meal.IsLocked = dto.IsLocked.Value;
        if (dto.RecipeId.HasValue)
        {
            var recipe = await _db.Recipes.AsNoTracking()
                .FirstOrDefaultAsync(r => r.Id == dto.RecipeId && r.IsActive && !r.IsDraft, ct);
            if (recipe is null || !recipe.Calories.HasValue) return null;
            meal.RecipeId = recipe.Id;
            meal.Calories = (int)Math.Round(recipe.Calories.Value * meal.Portions);
            meal.ProteinG = recipe.Protein.HasValue ? Math.Round(recipe.Protein.Value * meal.Portions, 2) : null;
            meal.FatG = recipe.Fat.HasValue ? Math.Round(recipe.Fat.Value * meal.Portions, 2) : null;
            meal.CarbsG = recipe.Carbs.HasValue ? Math.Round(recipe.Carbs.Value * meal.Portions, 2) : null;
        }

        if (dto.Portions.HasValue)
        {
            meal.Portions = dto.Portions.Value;
            var recipe = await _db.Recipes.AsNoTracking().FirstAsync(r => r.Id == meal.RecipeId, ct);
            meal.Calories = recipe.Calories.HasValue
                ? (int)Math.Round(recipe.Calories.Value * meal.Portions)
                : null;
            meal.ProteinG = recipe.Protein.HasValue ? Math.Round(recipe.Protein.Value * meal.Portions, 2) : null;
            meal.FatG = recipe.Fat.HasValue ? Math.Round(recipe.Fat.Value * meal.Portions, 2) : null;
            meal.CarbsG = recipe.Carbs.HasValue ? Math.Round(recipe.Carbs.Value * meal.Portions, 2) : null;
        }

        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);
        await SyncDraftMealIngredientsAsync(meal.Id, meal.RecipeId, meal.Portions, ct);
        await _db.SaveChangesAsync(ct);
        return await MapSessionAsync(sessionId, ct);
    }

    public async Task<CommitDietPlanResultDto?> CommitAsync(Guid userId, int sessionId, CancellationToken ct = default)
    {
        var session = await _db.DietGeneratorSessions
            .Include(s => s.DraftMeals)
            .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);

        if (session is null || session.Status != DietGeneratorStatuses.PlanDraft) return null;
        if (!session.ProposedCalories.HasValue || session.DraftMeals.Count == 0) return null;

        var start = session.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
        var end = start.AddDays(session.PlanDayCount - 1);

        var goals = await _db.UserDailyGoals.FirstOrDefaultAsync(g => g.UserId == userId, ct);
        if (goals is null)
        {
            goals = new UserDailyGoal { UserId = userId, CreatedAt = DateTime.UtcNow };
            _db.UserDailyGoals.Add(goals);
        }

        goals.CalorieGoal = session.ProposedCalories;
        goals.ProteinGoalG = session.ProposedProteinG;
        goals.FatGoalG = session.ProposedFatG;
        goals.CarbsGoalG = session.ProposedCarbsG;
        goals.UpdatedAt = DateTime.UtcNow;

        var existingEntries = await _db.MealPlanEntries
            .Where(e => e.UserId == userId && e.PlanDate >= start && e.PlanDate <= end)
            .ToListAsync(ct);
        _db.MealPlanEntries.RemoveRange(existingEntries);

        foreach (var draft in session.DraftMeals)
        {
            _db.MealPlanEntries.Add(new MealPlanEntry
            {
                UserId = userId,
                PlanDate = draft.PlanDate,
                MealCategory = draft.MealCategory,
                RecipeId = draft.RecipeId,
                Portions = draft.Portions,
                CreatedAt = DateTime.UtcNow,
            });
        }

        session.Status = DietGeneratorStatuses.Committed;
        session.CommittedAt = DateTime.UtcNow;
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);

        return new CommitDietPlanResultDto
        {
            SessionId = sessionId,
            PlanStartDate = start,
            PlanEndDate = end,
            MealsWritten = session.DraftMeals.Count,
        };
    }

    private async Task BackfillMissingDraftIngredientsAsync(Guid userId, int sessionId, CancellationToken ct)
    {
        var session = await _db.DietGeneratorSessions.AsNoTracking()
            .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);
        if (session?.Status != DietGeneratorStatuses.PlanDraft) return;

        var meals = await _db.DietGeneratorDraftMeals
            .Where(m => m.SessionId == sessionId)
            .ToListAsync(ct);

        if (meals.Count == 0) return;

        foreach (var meal in meals)
        {
            await SyncDraftMealIngredientsAsync(meal.Id, meal.RecipeId, meal.Portions, ct);
        }

        await _db.SaveChangesAsync(ct);
    }

    private async Task SyncDraftMealIngredientsAsync(
        int draftMealId,
        int recipeId,
        decimal portions,
        CancellationToken ct)
    {
        var existing = await _db.DietGeneratorDraftMealIngredients
            .Where(i => i.DraftMealId == draftMealId)
            .ToListAsync(ct);
        _db.DietGeneratorDraftMealIngredients.RemoveRange(existing);

        var recipeIngredients = await _db.Ingredients.AsNoTracking()
            .Where(i => i.RecipeId == recipeId)
            .OrderBy(i => i.SortOrder)
            .ThenBy(i => i.Id)
            .ToListAsync(ct);

        foreach (var ingredient in recipeIngredients)
        {
            var (scaledGrams, scaledUnit) = IngredientScalingHelper.ScaleForDietPlan(
                ingredient.AmountGrams,
                ingredient.Unit,
                portions);

            _db.DietGeneratorDraftMealIngredients.Add(new DietGeneratorDraftMealIngredient
            {
                DraftMealId = draftMealId,
                SourceIngredientId = ingredient.Id,
                Name = ingredient.Name,
                AmountGrams = scaledGrams,
                Unit = scaledUnit,
                SortOrder = ingredient.SortOrder,
            });
        }
    }

    private static void ApplyCalculatedMacros(DietGeneratorSession session, TdeeCalculator.MacroTargets calculated)
    {
        session.ProposedCalories = calculated.Calories;
        session.ProposedProteinG = calculated.ProteinG;
        session.ProposedFatG = calculated.FatG;
        session.ProposedCarbsG = calculated.CarbsG;
        session.MacroRationale = calculated.Rationale;
    }

    private List<DraftMealSlot> BuildGreedyPlan(
        DietGeneratorSession session,
        IReadOnlyList<RecipeCandidate> pool,
        IReadOnlyList<int> mealCategories,
        DateOnly start,
        HashSet<int> usedRecipes)
    {
        var allSlots = new List<DraftMealSlot>();
        for (var day = 0; day < session.PlanDayCount; day++)
        {
            var date = start.AddDays(day);
            var daySlots = MealPlanOptimizer.BuildGreedyDay(
                date,
                pool,
                mealCategories,
                session.ProposedCalories!.Value,
                session.CalorieToleranceKcal,
                usedRecipes);
            allSlots.AddRange(daySlots);
        }

        return allSlots;
    }

    private async Task<List<DraftMealSlot>> GeneratePlanWithAiAsync(
        DietGeneratorSession session,
        IReadOnlyList<RecipeCandidate> pool,
        IReadOnlyList<int> mealCategories,
        DateOnly start,
        HashSet<int> usedRecipes,
        CancellationToken ct)
    {
        var compact = pool.Take(120).Select(r => new
        {
            r.Id,
            r.Name,
            r.MealCategory,
            r.Calories,
            r.Protein,
            r.Fat,
            r.Carbs,
        });

        var system = """
            You are a meal planner. Pick ONLY recipe ids from the provided catalog.
            Respond JSON: {"days":[{"date":"YYYY-MM-DD","meals":[{"mealCategory":0,"recipeId":1,"portions":1.0}]}]}
            Each day must use allowed meal categories only.
            Never reuse the same recipeId anywhere in the plan — each recipe may appear at most once across all days.
            """;
        var user = JsonSerializer.Serialize(new
        {
            targetCaloriesPerDay = session.ProposedCalories,
            toleranceKcal = session.CalorieToleranceKcal,
            mealCategories,
            planStartDate = start.ToString("yyyy-MM-dd"),
            planDayCount = session.PlanDayCount,
            recipes = compact,
        });

        var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Plan, ct);
        using var doc = JsonDocument.Parse(json);
        var poolMap = pool.ToDictionary(r => r.Id);
        var allSlots = new List<DraftMealSlot>();

        if (!doc.RootElement.TryGetProperty("days", out var daysEl)) return BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);

        foreach (var dayEl in daysEl.EnumerateArray())
        {
            var dateStr = dayEl.GetProperty("date").GetString();
            if (!DateOnly.TryParse(dateStr, out var date)) continue;

            var daySlots = new List<DraftMealSlot>();
            var perSlot = mealCategories.Count > 0
                ? session.ProposedCalories!.Value / mealCategories.Count
                : session.ProposedCalories!.Value;
            foreach (var mealEl in dayEl.GetProperty("meals").EnumerateArray())
            {
                var category = mealEl.GetProperty("mealCategory").GetInt32();
                var recipeId = mealEl.GetProperty("recipeId").GetInt32();
                DraftMealSlot slot;

                if (!poolMap.TryGetValue(recipeId, out var recipe) || usedRecipes.Contains(recipeId))
                {
                    slot = MealPlanOptimizer.PickGreedyMeal(pool, category, perSlot, usedRecipes);
                    slot.PlanDate = date;
                }
                else
                {
                    var portions = mealEl.TryGetProperty("portions", out var p) ? p.GetDecimal() : 1m;
                    slot = new DraftMealSlot
                    {
                        PlanDate = date,
                        MealCategory = category,
                        RecipeId = recipeId,
                        Portions = Math.Clamp(portions, 0.5m, 2.5m),
                        CaloriesPerPortion = recipe.Calories,
                        ProteinPerPortion = recipe.Protein,
                        FatPerPortion = recipe.Fat,
                        CarbsPerPortion = recipe.Carbs,
                    };
                }

                daySlots.Add(slot);
                usedRecipes.Add(slot.RecipeId);
            }

            if (daySlots.Count == 0) continue;
            MealPlanOptimizer.BalanceDay(daySlots, session.ProposedCalories!.Value, session.CalorieToleranceKcal);
            allSlots.AddRange(daySlots);
        }

        if (allSlots.Count == 0) return BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
        return allSlots;
    }

    private async Task<List<RecipeCandidate>> LoadRecipePoolAsync(Guid userId, CancellationToken ct)
    {
        return await _db.Recipes.AsNoTracking()
            .Where(r => r.IsActive && !r.IsDraft && r.Calories.HasValue)
            .Where(r => r.CreatedByUserId == null || r.CreatedByUserId == userId)
            .Select(r => new RecipeCandidate(
                r.Id,
                r.Name,
                r.MealCategory,
                r.Calories!.Value,
                r.Protein ?? 0,
                r.Fat ?? 0,
                r.Carbs ?? 0))
            .ToListAsync(ct);
    }

    private async Task<DietGeneratorSession?> GetEditableSessionAsync(Guid userId, int sessionId, CancellationToken ct) =>
        await _db.DietGeneratorSessions.FirstOrDefaultAsync(
            s => s.Id == sessionId && s.UserId == userId && s.Status != DietGeneratorStatuses.Committed, ct);

    private Task<DietGeneratorSessionDto?> MapSessionAsync(int sessionId, CancellationToken ct) =>
        MapSessionAsync(sessionId, null, ct);

    private async Task<DietGeneratorSessionDto?> MapSessionAsync(int sessionId, Guid? userId, CancellationToken ct)
    {
        var query = _db.DietGeneratorSessions.AsNoTracking()
            .Include(s => s.DraftMeals)
            .ThenInclude(m => m.Recipe)
            .ThenInclude(r => r!.Steps)
            .Include(s => s.DraftMeals)
            .ThenInclude(m => m.PlannedIngredients)
            .Where(s => s.Id == sessionId);

        if (userId.HasValue) query = query.Where(s => s.UserId == userId.Value);

        var session = await query.FirstOrDefaultAsync(ct);
        if (session is null) return null;

        var meals = session.DraftMeals
            .OrderBy(m => m.PlanDate)
            .ThenBy(m => m.MealCategory)
            .Select(m => new DietGeneratorDraftMealDto
            {
                Id = m.Id,
                PlanDate = m.PlanDate,
                MealCategory = m.MealCategory,
                RecipeId = m.RecipeId,
                RecipeName = m.Recipe?.Name ?? string.Empty,
                Portions = m.Portions,
                Calories = m.Calories,
                ProteinG = m.ProteinG,
                FatG = m.FatG,
                CarbsG = m.CarbsG,
                IsLocked = m.IsLocked,
                PrepTimeMinutes = m.Recipe?.PrepTimeMinutes,
                Ingredients = m.PlannedIngredients
                    .OrderBy(i => i.SortOrder)
                    .ThenBy(i => i.Id)
                    .Select(i => new DietGeneratorDraftMealIngredientDto
                    {
                        Id = i.Id,
                        Name = i.Name,
                        AmountGrams = i.AmountGrams,
                        Unit = i.Unit,
                        SortOrder = i.SortOrder,
                        SourceIngredientId = i.SourceIngredientId,
                    })
                    .ToList(),
                Steps = (m.Recipe?.Steps ?? [])
                    .OrderBy(s => s.StepNumber)
                    .Select(s => new DietGeneratorDraftMealStepDto
                    {
                        Id = s.Id,
                        StepNumber = s.StepNumber,
                        Description = s.Description,
                    })
                    .ToList(),
            })
            .ToList();

        var summaries = meals
            .GroupBy(m => m.PlanDate)
            .Select(g => new DietGeneratorDaySummaryDto
            {
                PlanDate = g.Key,
                TotalCalories = g.Sum(x => x.Calories ?? 0),
                TargetCalories = session.ProposedCalories ?? 0,
                WithinTolerance = session.ProposedCalories.HasValue &&
                    Math.Abs(g.Sum(x => x.Calories ?? 0) - session.ProposedCalories.Value) <= session.CalorieToleranceKcal,
            })
            .ToList();

        return new DietGeneratorSessionDto
        {
            Id = session.Id,
            Status = session.Status,
            ProposedMacros = session.ProposedCalories.HasValue
                ? new MacroProposalDto
                {
                    Calories = session.ProposedCalories.Value,
                    ProteinG = session.ProposedProteinG ?? 0,
                    FatG = session.ProposedFatG ?? 0,
                    CarbsG = session.ProposedCarbsG ?? 0,
                    Rationale = session.MacroRationale,
                }
                : null,
            PlanStartDate = session.PlanStartDate,
            PlanDayCount = session.PlanDayCount,
            CalorieToleranceKcal = session.CalorieToleranceKcal,
            DraftMeals = meals,
            DaySummaries = summaries,
        };
    }

    private static DietUserProfileDto MapProfile(DietUserProfile profile) => new()
    {
        HeightCm = profile.HeightCm,
        WeightKg = profile.WeightKg,
        Age = profile.Age,
        Sex = profile.Sex,
        ActivityLevel = profile.ActivityLevel,
        Goal = profile.Goal,
        AllergiesNotes = profile.AllergiesNotes,
        DislikedFoods = profile.DislikedFoods,
        MealsPerDayMask = profile.MealsPerDayMask,
    };
}
