namespace MealPlan.Api.Services.Generators;

public record RecipeCandidate(
    int Id,
    string Name,
    int MealCategory,
    int Calories,
    decimal Protein,
    decimal Fat,
    decimal Carbs);

public class DraftMealSlot
{
    public DateOnly PlanDate { get; set; }
    public int MealCategory { get; set; }
    public int RecipeId { get; set; }
    public decimal Portions { get; set; } = 1m;
    public int CaloriesPerPortion { get; set; }
    public decimal ProteinPerPortion { get; set; }
    public decimal FatPerPortion { get; set; }
    public decimal CarbsPerPortion { get; set; }
    public bool IsLocked { get; set; }

    public int TotalCalories => (int)Math.Round(CaloriesPerPortion * Portions);
    public decimal TotalProtein => ProteinPerPortion * Portions;
    public decimal TotalFat => FatPerPortion * Portions;
    public decimal TotalCarbs => CarbsPerPortion * Portions;

    public DraftMealSlot CloneForDate(DateOnly planDate) =>
        new()
        {
            PlanDate = planDate,
            MealCategory = MealCategory,
            RecipeId = RecipeId,
            Portions = Portions,
            CaloriesPerPortion = CaloriesPerPortion,
            ProteinPerPortion = ProteinPerPortion,
            FatPerPortion = FatPerPortion,
            CarbsPerPortion = CarbsPerPortion,
            IsLocked = IsLocked,
        };
}

public static class MealPlanOptimizer
{
    public static IReadOnlyList<int> MealCategoriesFromMask(int mask) =>
        Enumerable.Range(0, 5).Where(i => (mask & (1 << i)) != 0).ToList();

    public static void BalanceDay(IList<DraftMealSlot> meals, int targetCalories, int tolerance)
    {
        if (meals.Count == 0) return;

        var unlocked = meals.Where(m => !m.IsLocked).ToList();
        if (unlocked.Count == 0) return;

        var current = meals.Sum(m => m.TotalCalories);
        if (Math.Abs(current - targetCalories) <= tolerance) return;

        if (current <= 0)
        {
            foreach (var meal in unlocked)
            {
                meal.Portions = 1m;
            }

            current = meals.Sum(m => m.TotalCalories);
        }

        if (current <= 0) return;

        var scale = (decimal)targetCalories / current;
        foreach (var meal in unlocked)
        {
            meal.Portions = Math.Clamp(Math.Round(meal.Portions * scale, 2), 0.5m, 2.5m);
        }

        current = meals.Sum(m => m.TotalCalories);
        if (Math.Abs(current - targetCalories) <= tolerance) return;

        var diff = targetCalories - current;
        var adjustable = unlocked.OrderByDescending(m => m.CaloriesPerPortion).FirstOrDefault();
        if (adjustable is null || adjustable.CaloriesPerPortion <= 0) return;

        var portionDelta = diff / (decimal)adjustable.CaloriesPerPortion;
        adjustable.Portions = Math.Clamp(Math.Round(adjustable.Portions + portionDelta, 2), 0.5m, 2.5m);
    }

    public static bool IsDayWithinTolerance(IEnumerable<DraftMealSlot> meals, int targetCalories, int tolerance) =>
        Math.Abs(meals.Sum(m => m.TotalCalories) - targetCalories) <= tolerance;

    private static RecipeCandidate? FindGreedyCandidate(
        IReadOnlyList<RecipeCandidate> pool,
        int mealCategory,
        int targetCaloriesForSlot,
        HashSet<int> excludedRecipeIds)
    {
        var candidates = pool
            .Where(r => r.MealCategory == mealCategory && !excludedRecipeIds.Contains(r.Id))
            .OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot))
            .ToList();

        if (candidates.Count == 0)
        {
            candidates = pool
                .Where(r => !excludedRecipeIds.Contains(r.Id))
                .OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot))
                .ToList();
        }

        return candidates.FirstOrDefault();
    }

    public static bool TryPickGreedyMeal(
        IReadOnlyList<RecipeCandidate> pool,
        int mealCategory,
        int targetCaloriesForSlot,
        HashSet<int> excludedRecipeIds,
        out DraftMealSlot slot)
    {
        var recipe = FindGreedyCandidate(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds);
        if (recipe is null)
        {
            slot = null!;
            return false;
        }

        var portions = recipe.Calories > 0
            ? Math.Clamp(Math.Round((decimal)targetCaloriesForSlot / recipe.Calories, 2), 0.5m, 2.5m)
            : 1m;

        slot = new DraftMealSlot
        {
            MealCategory = mealCategory,
            RecipeId = recipe.Id,
            Portions = portions,
            CaloriesPerPortion = recipe.Calories,
            ProteinPerPortion = recipe.Protein,
            FatPerPortion = recipe.Fat,
            CarbsPerPortion = recipe.Carbs,
        };
        return true;
    }

    public static DraftMealSlot PickGreedyMeal(
        IReadOnlyList<RecipeCandidate> pool,
        int mealCategory,
        int targetCaloriesForSlot,
        HashSet<int> excludedRecipeIds)
    {
        if (TryPickGreedyMeal(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds, out var slot))
        {
            return slot;
        }

        throw new InvalidOperationException("No recipes available for meal planning.");
    }

    public static IReadOnlyList<DraftMealSlot> BuildGreedyDay(
        DateOnly planDate,
        IReadOnlyList<RecipeCandidate> pool,
        IReadOnlyList<int> mealCategories,
        int targetCalories,
        int tolerance,
        HashSet<int> excludedRecipeIds)
    {
        var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories;
        var meals = new List<DraftMealSlot>();

        foreach (var category in mealCategories)
        {
            var slot = PickGreedyMeal(pool, category, perSlot, excludedRecipeIds);
            slot.PlanDate = planDate;
            meals.Add(slot);
            excludedRecipeIds.Add(slot.RecipeId);
        }

        BalanceDay(meals, targetCalories, tolerance);
        return meals;
    }

    public static int RequiredUniqueRecipes(int planDayCount, IReadOnlyList<int> mealCategories) =>
        planDayCount * mealCategories.Count;

    /// <summary>
    /// Replaces duplicate recipe picks so each recipe appears at most once in the plan.
    /// </summary>
    public static void EnsureWeeklyUniqueRecipes(
        IList<DraftMealSlot> slots,
        IReadOnlyList<RecipeCandidate> pool,
        IReadOnlyList<int> mealCategories,
        int targetCalories,
        int tolerance)
    {
        if (slots.Count == 0) return;

        var used = new HashSet<int>();
        var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories;

        foreach (var slot in slots.OrderBy(s => s.PlanDate).ThenBy(s => s.MealCategory))
        {
            if (used.Add(slot.RecipeId)) continue;

            var replacement = PickGreedyMeal(pool, slot.MealCategory, perSlot, used);
            CopyRecipeIntoSlot(slot, replacement);
            used.Add(slot.RecipeId);

            var daySlots = slots.Where(s => s.PlanDate == slot.PlanDate).ToList();
            BalanceDay(daySlots, targetCalories, tolerance);
        }
    }

    private static void CopyRecipeIntoSlot(DraftMealSlot target, DraftMealSlot source)
    {
        target.RecipeId = source.RecipeId;
        target.Portions = source.Portions;
        target.CaloriesPerPortion = source.CaloriesPerPortion;
        target.ProteinPerPortion = source.ProteinPerPortion;
        target.FatPerPortion = source.FatPerPortion;
        target.CarbsPerPortion = source.CarbsPerPortion;
    }
}
