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

namespace MealPlan.Api.Services.Generators;

public interface IRecipeGeneratorService
{
    Task<RecipeGeneratorSessionDto> CreateSessionAsync(Guid userId, RecipeGeneratorConstraintsDto dto, CancellationToken ct = default);
    Task<RecipeGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default);
    Task<RecipeGeneratorSessionDto?> GenerateAsync(Guid userId, int sessionId, GenerateRecipesRequestDto? request, CancellationToken ct = default);
    Task<RecipeGeneratorSessionDto?> RegeneratePartAsync(Guid userId, int sessionId, RegenerateRecipePartDto dto, CancellationToken ct = default);
    Task<RecipeGeneratorSessionDto?> UpdateDraftAsync(Guid userId, int sessionId, GeneratedRecipeDraftDto dto, CancellationToken ct = default);
    Task<RecipeGeneratorSessionDto?> RemoveDraftAsync(Guid userId, int sessionId, string draftId, CancellationToken ct = default);
    Task<CommitRecipesResultDto?> CommitAsync(Guid userId, int sessionId, CommitRecipesRequestDto? request, CancellationToken ct = default);
}

public class RecipeGeneratorService : IRecipeGeneratorService
{
    private readonly AppDbContext _db;
    private readonly IOpenAiChatService _openAi;
    private readonly ICatalogMatchingService _catalogMatching;
    private readonly IRecipeManagementService _recipeManagement;

    public RecipeGeneratorService(
        AppDbContext db,
        IOpenAiChatService openAi,
        ICatalogMatchingService catalogMatching,
        IRecipeManagementService recipeManagement)
    {
        _db = db;
        _openAi = openAi;
        _catalogMatching = catalogMatching;
        _recipeManagement = recipeManagement;
    }

    public async Task<RecipeGeneratorSessionDto> CreateSessionAsync(
        Guid userId,
        RecipeGeneratorConstraintsDto dto,
        CancellationToken ct = default)
    {
        var session = new RecipeGeneratorSession
        {
            UserId = userId,
            Status = RecipeGeneratorStatuses.Draft,
            ConstraintsJson = JsonSerializer.Serialize(dto),
            CreatedAt = DateTime.UtcNow,
        };
        _db.RecipeGeneratorSessions.Add(session);
        await _db.SaveChangesAsync(ct);
        return MapSession(session, dto, []);
    }

    public async Task<RecipeGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default)
    {
        var session = await _db.RecipeGeneratorSessions.AsNoTracking()
            .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);
        if (session is null) return null;
        var constraints = DeserializeConstraints(session.ConstraintsJson);
        var drafts = DeserializeDrafts(session.DraftJson);
        return MapSession(session, constraints, drafts);
    }

    public async Task<RecipeGeneratorSessionDto?> GenerateAsync(
        Guid userId,
        int sessionId,
        GenerateRecipesRequestDto? request,
        CancellationToken ct = default)
    {
        var session = await _db.RecipeGeneratorSessions
            .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
        if (session is null) return null;

        if (!_openAi.IsConfigured)
        {
            throw new InvalidOperationException(
                "OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
        }

        var count = Math.Clamp(request?.Count ?? 3, 1, 8);
        var constraints = DeserializeConstraints(session.ConstraintsJson);
        if (string.IsNullOrWhiteSpace(constraints.Prompt))
        {
            throw new InvalidOperationException("Recipe prompt is required.");
        }

        var catalogSample = await _db.IngredientNutritionCatalog.AsNoTracking()
            .Where(i => i.IsActive)
            .OrderBy(i => i.Name)
            .Take(80)
            .Select(i => new { i.Name, i.NamePl, i.CaloriesPer100G, i.ProteinGPer100G, i.FatGPer100G, i.CarbsGPer100G })
            .ToListAsync(ct);

        var drafts = await GenerateDistinctDraftsAsync(constraints, catalogSample, count, ct);
        if (drafts.Count == 0)
        {
            throw new InvalidOperationException("AI did not return any valid recipes. Try again.");
        }

        var enriched = new List<GeneratedRecipeDraftDto>();
        foreach (var draft in drafts)
        {
            enriched.Add(await EnrichDraftAsync(EnsureDraftId(draft), constraints, ct));
        }

        session.DraftJson = SerializeDrafts(enriched);
        session.GenerationVersion++;
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);
        return MapSession(session, constraints, enriched);
    }

    public async Task<RecipeGeneratorSessionDto?> RegeneratePartAsync(
        Guid userId,
        int sessionId,
        RegenerateRecipePartDto dto,
        CancellationToken ct = default)
    {
        var session = await _db.RecipeGeneratorSessions
            .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
        if (session is null) return null;

        var constraints = DeserializeConstraints(session.ConstraintsJson);
        var drafts = DeserializeDrafts(session.DraftJson);
        if (string.IsNullOrWhiteSpace(dto.DraftId))
            return MapSession(session, constraints, drafts);

        var index = drafts.FindIndex(d => d.DraftId == dto.DraftId);
        if (index < 0) return null;

        var current = drafts[index];
        if (!_openAi.IsConfigured)
        {
            throw new InvalidOperationException(
                "OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
        }

        var mode = dto.Mode.ToLowerInvariant();
        var system = mode switch
        {
            "steps" => """
                Rewrite ONLY cooking steps for the recipe. JSON: {"steps":[{"stepNumber":1,"description":"..."}]}
                Keep ingredients unchanged.
                """,
            "ingredients" => """
                Rewrite ONLY ingredients list. JSON: {"ingredients":[{"name":"","amountGrams":0,"unit":null,"catalogName":""}]}
                Keep name and steps unchanged.
                """,
            _ => """
                Improve the full recipe. Respond ONLY JSON with name, mealCategory, prepTimeMinutes, ingredients, steps.
                Use JSON numbers (not strings) for mealCategory, prepTimeMinutes, amountGrams, stepNumber.
                """,
        };

        var user = JsonSerializer.Serialize(new
        {
            instruction = dto.Instruction ?? constraints.Prompt,
            current,
            constraints,
        });

        var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
        try
        {
            using var doc = JsonDocument.Parse(json);
            var root = doc.RootElement;

            if (mode == "steps" && root.TryGetProperty("steps", out var stepsEl))
            {
                current.Steps = ParseSteps(stepsEl);
            }
            else if (mode == "ingredients" && root.TryGetProperty("ingredients", out var ingEl))
            {
                current.Ingredients = await ParseIngredientsAsync(ingEl, ct);
            }
            else
            {
                var parsed = ParseDraft(json, constraints.MealCategory);
                parsed.DraftId = current.DraftId;
                current = parsed;
            }
        }
        catch (JsonException ex)
        {
            throw new InvalidOperationException("Could not parse AI recipe response. Try again.", ex);
        }

        current = await EnrichDraftAsync(EnsureDraftId(current), constraints, ct);
        drafts[index] = current;
        session.DraftJson = SerializeDrafts(drafts);
        session.GenerationVersion++;
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);
        return MapSession(session, constraints, drafts);
    }

    public async Task<RecipeGeneratorSessionDto?> UpdateDraftAsync(
        Guid userId,
        int sessionId,
        GeneratedRecipeDraftDto dto,
        CancellationToken ct = default)
    {
        var session = await _db.RecipeGeneratorSessions
            .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
        if (session is null) return null;

        var drafts = DeserializeDrafts(session.DraftJson);
        var constraints = DeserializeConstraints(session.ConstraintsJson);
        var enriched = await EnrichDraftAsync(EnsureDraftId(dto), constraints, ct);
        var index = drafts.FindIndex(d => d.DraftId == enriched.DraftId);
        if (index < 0)
        {
            drafts.Add(enriched);
        }
        else
        {
            drafts[index] = enriched;
        }

        session.DraftJson = SerializeDrafts(drafts);
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);
        return MapSession(session, constraints, drafts);
    }

    public async Task<RecipeGeneratorSessionDto?> RemoveDraftAsync(
        Guid userId,
        int sessionId,
        string draftId,
        CancellationToken ct = default)
    {
        var session = await _db.RecipeGeneratorSessions
            .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
        if (session is null) return null;

        var drafts = DeserializeDrafts(session.DraftJson);
        var removed = drafts.RemoveAll(d => d.DraftId == draftId);
        if (removed == 0) return null;

        session.DraftJson = SerializeDrafts(drafts);
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);
        var constraints = DeserializeConstraints(session.ConstraintsJson);
        return MapSession(session, constraints, drafts);
    }

    public async Task<CommitRecipesResultDto?> CommitAsync(
        Guid userId,
        int sessionId,
        CommitRecipesRequestDto? request,
        CancellationToken ct = default)
    {
        var session = await _db.RecipeGeneratorSessions
            .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
        if (session is null || string.IsNullOrWhiteSpace(session.DraftJson)) return null;

        var drafts = DeserializeDrafts(session.DraftJson);
        if (drafts.Count == 0) return null;

        var targetIds = request?.DraftIds?.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
        var toSave = targetIds is { Count: > 0 }
            ? drafts.Where(d => targetIds.Contains(d.DraftId)).ToList()
            : drafts.ToList();

        if (toSave.Count == 0) return null;

        var saved = new List<CommitRecipeResultDto>();
        foreach (var draft in toSave)
        {
            if (draft.Ingredients.Count == 0 || draft.Steps.Count == 0) continue;

            var input = new CreateRecipeDto
            {
                Name = draft.Name,
                MealCategory = draft.MealCategory,
                Calories = draft.Calories,
                Protein = draft.Protein,
                Fat = draft.Fat,
                Carbs = draft.Carbs,
                PrepTimeMinutes = draft.PrepTimeMinutes,
                Ingredients = draft.Ingredients.Select((i, index) => new CreateIngredientDto
                {
                    Name = i.Name,
                    AmountGrams = i.AmountGrams,
                    Unit = i.Unit,
                    SortOrder = index,
                    CatalogItemId = i.CatalogItemId,
                }).ToList(),
                Steps = draft.Steps.Select(s => new CreateRecipeStepDto
                {
                    StepNumber = s.StepNumber,
                    Description = s.Description,
                }).ToList(),
            };

            CommitRecipeResultDto savedEntry;
            try
            {
                var created = await _recipeManagement.CreateRecipeAsync(input, ct);
                var entity = await _db.Recipes.FirstAsync(r => r.Id == created.Id, ct);
                entity.Source = "ai_generated";
                entity.CreatedByUserId = userId;
                entity.IsDraft = false;

                savedEntry = new CommitRecipeResultDto
                {
                    SessionId = sessionId,
                    RecipeId = created.Id,
                    RecipeName = created.Name,
                    DraftId = draft.DraftId,
                };
            }
            catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" })
            {
                throw new InvalidOperationException(
                    "Could not save the recipe because the database recipe ID sequence is out of sync. " +
                    "Run: dotnet run --project MealPlan.Api -- --fix-sequences",
                    ex);
            }

            saved.Add(savedEntry);
        }

        if (saved.Count == 0) return null;

        var savedIds = saved.Select(s => s.DraftId).ToHashSet();
        drafts.RemoveAll(d => savedIds.Contains(d.DraftId));
        session.DraftJson = SerializeDrafts(drafts);
        session.SavedRecipeId = saved[^1].RecipeId;
        session.Status = drafts.Count == 0 ? RecipeGeneratorStatuses.Saved : RecipeGeneratorStatuses.Draft;
        session.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);

        return new CommitRecipesResultDto
        {
            SessionId = sessionId,
            Saved = saved,
        };
    }

    private async Task<GeneratedRecipeDraftDto> EnrichDraftAsync(
        GeneratedRecipeDraftDto draft,
        RecipeGeneratorConstraintsDto constraints,
        CancellationToken ct)
    {
        var ingredients = new List<GeneratedIngredientDto>();
        foreach (var ing in draft.Ingredients)
        {
            var catalogId = ing.CatalogItemId;
            if (!catalogId.HasValue)
            {
                catalogId = await _catalogMatching.ResolveCatalogItemIdAsync(
                    ing.CatalogName ?? ing.Name, ct);
            }

            ingredients.Add(new GeneratedIngredientDto
            {
                Name = ing.Name,
                AmountGrams = ing.AmountGrams,
                Unit = ing.Unit,
                CatalogItemId = catalogId,
                CatalogName = ing.CatalogName,
                IsLinked = catalogId.HasValue,
            });
        }

        draft.Ingredients = ingredients;
        draft.UnlinkedIngredientCount = ingredients.Count(i =>
            i.AmountGrams is > 0 &&
            !IsSpiceUnit(i.Unit) &&
            !i.IsLinked);

        await ScaleDraftToTargetCaloriesAsync(draft, constraints, ct);
        await ApplyComputedMacrosAsync(draft, ct);
        return draft;
    }

    private async Task ApplyComputedMacrosAsync(GeneratedRecipeDraftDto draft, CancellationToken ct)
    {
        var macros = await ComputeMacrosAsync(draft.Ingredients, ct);
        if (!macros.HasValue) return;

        draft.Calories = macros.Value.Calories;
        draft.Protein = macros.Value.Protein;
        draft.Fat = macros.Value.Fat;
        draft.Carbs = macros.Value.Carbs;
    }

    private async Task ScaleDraftToTargetCaloriesAsync(
        GeneratedRecipeDraftDto draft,
        RecipeGeneratorConstraintsDto constraints,
        CancellationToken ct)
    {
        if (constraints.TargetCalories is not > 0) return;

        var target = constraints.TargetCalories.Value;
        var tolerance = constraints.CalorieToleranceKcal > 0 ? constraints.CalorieToleranceKcal : 10;
        var currentMacros = await ComputeMacrosAsync(draft.Ingredients, ct);
        if (currentMacros is null || currentMacros.Value.Calories <= 0) return;
        if (Math.Abs(currentMacros.Value.Calories - target) <= tolerance) return;

        var scale = Math.Clamp((decimal)target / currentMacros.Value.Calories, 0.25m, 4m);
        foreach (var ing in draft.Ingredients)
        {
            if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || IsSpiceUnit(ing.Unit)) continue;
            ing.AmountGrams = Math.Round(ing.AmountGrams.Value * scale, 1);
        }
    }

    private async Task<(int Calories, decimal Protein, decimal Fat, decimal Carbs)?> ComputeMacrosAsync(
        IReadOnlyList<GeneratedIngredientDto> ingredients,
        CancellationToken ct)
    {
        var ids = ingredients.Where(i => i.CatalogItemId.HasValue && i.AmountGrams is > 0 && !IsSpiceUnit(i.Unit))
            .Select(i => i.CatalogItemId!.Value)
            .Distinct()
            .ToList();
        if (ids.Count == 0) return null;

        var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
            .Where(i => ids.Contains(i.Id))
            .ToDictionaryAsync(i => i.Id, ct);

        int calories = 0;
        decimal protein = 0, fat = 0, carbs = 0;
        foreach (var ing in ingredients)
        {
            if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || IsSpiceUnit(ing.Unit)) continue;
            if (!catalog.TryGetValue(ing.CatalogItemId.Value, out var cat)) continue;
            var scale = ing.AmountGrams.Value / 100m;
            calories += (int)Math.Round(cat.CaloriesPer100G * scale);
            protein += cat.ProteinGPer100G * scale;
            fat += cat.FatGPer100G * scale;
            carbs += cat.CarbsGPer100G * scale;
        }

        return (calories, Math.Round(protein, 2), Math.Round(fat, 2), Math.Round(carbs, 2));
    }

    private async Task<List<GeneratedRecipeDraftDto>> GenerateDistinctDraftsAsync(
        RecipeGeneratorConstraintsDto constraints,
        object catalogSample,
        int count,
        CancellationToken ct)
    {
        const int maxAttempts = 4;
        var drafts = new List<GeneratedRecipeDraftDto>();

        for (var attempt = 0; attempt < maxAttempts && drafts.Count < count; attempt++)
        {
            var need = count - drafts.Count;
            var json = await RequestRecipeBatchJsonAsync(
                constraints,
                catalogSample,
                need,
                drafts.Select(d => d.Name).ToList(),
                count,
                ct);
            var parsed = ParseDrafts(json, constraints.MealCategory);

            foreach (var draft in parsed)
            {
                if (IsDuplicateDraft(draft, drafts)) continue;
                drafts.Add(draft);
                if (drafts.Count >= count) break;
            }
        }

        return drafts.Take(count).ToList();
    }

    private async Task<string> RequestRecipeBatchJsonAsync(
        RecipeGeneratorConstraintsDto constraints,
        object catalogSample,
        int recipeCount,
        IReadOnlyList<string> excludeNames,
        int totalRequested,
        CancellationToken ct)
    {
        var system = """
            You create realistic Polish home-cooking recipes for a meal planning app.
            Respond ONLY JSON:
            {"recipes":[{"name":"string","mealCategory":0,"prepTimeMinutes":20,"ingredients":[{"name":"Polish name","amountGrams":100,"unit":null,"catalogName":"English catalog name"}],"steps":[{"stepNumber":1,"description":"..."}]}]}
            Rules:
            - Follow constraints.prompt strictly for every recipe (dish type, main ingredients, style).
            - If constraints.targetCalories is set, each recipe MUST total about that many kcal using realistic ingredient grams.
              Aim within ±constraints.calorieToleranceKcal kcal. Do NOT put the calorie target only in the name — scale amounts.
            - Use catalogName from the provided catalog when possible; grams required for macro ingredients.
            - Spices may use unit "do smaku" with null amountGrams.
            - mealCategory: 0=Breakfast, 1=SecondBreakfast, 2=Lunch, 3=Dinner, 4=Snack.
            - Return exactly recipeCount recipes in the recipes array.
            - Each recipe MUST be clearly different (unique name, different ingredient mix, different preparation).
            - Do NOT repeat the same recipe with only a number suffix.
            - Do NOT copy any name from excludeExistingNames.
            """;
        var user = JsonSerializer.Serialize(new
        {
            constraints,
            catalog = catalogSample,
            recipeCount,
            totalRequested,
            excludeExistingNames = excludeNames,
        });
        return await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
    }

    private static bool IsDuplicateDraft(GeneratedRecipeDraftDto candidate, IReadOnlyList<GeneratedRecipeDraftDto> existing) =>
        existing.Any(existingDraft => DraftFingerprint(existingDraft) == DraftFingerprint(candidate));

    private static string DraftFingerprint(GeneratedRecipeDraftDto draft)
    {
        var ingredients = string.Join(
            "|",
            draft.Ingredients
                .OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
                .Select(i => $"{i.Name.Trim().ToLowerInvariant()}:{i.AmountGrams}:{i.Unit}"));
        return $"{NormalizeRecipeName(draft.Name)}::{ingredients}";
    }

    private static string NormalizeRecipeName(string name) =>
        name.Trim().ToLowerInvariant();

    private GeneratedRecipeDraftDto ParseDraft(string json, int defaultCategory)
    {
        using var doc = JsonDocument.Parse(json);
        var root = doc.RootElement;
        return EnsureDraftId(new GeneratedRecipeDraftDto
        {
            Name = root.TryGetProperty("name", out var n) ? AiRecipeJsonParser.GetString(n) ?? "Nowy przepis" : "Nowy przepis",
            MealCategory = root.TryGetProperty("mealCategory", out var mc)
                ? AiRecipeJsonParser.GetInt32(mc, defaultCategory)
                : defaultCategory,
            PrepTimeMinutes = root.TryGetProperty("prepTimeMinutes", out var pt)
                ? AiRecipeJsonParser.GetNullableInt32(pt)
                : null,
            Ingredients = root.TryGetProperty("ingredients", out var ing) ? ParseIngredientsSync(ing) : [],
            Steps = root.TryGetProperty("steps", out var st) ? ParseSteps(st) : [],
        });
    }

    private List<GeneratedRecipeDraftDto> ParseDrafts(string json, int defaultCategory)
    {
        using var doc = JsonDocument.Parse(json);
        var root = doc.RootElement;
        var list = new List<GeneratedRecipeDraftDto>();

        if (root.TryGetProperty("recipes", out var recipesEl) && recipesEl.ValueKind == JsonValueKind.Array)
        {
            foreach (var item in recipesEl.EnumerateArray())
            {
                list.Add(EnsureDraftId(ParseDraft(item.GetRawText(), defaultCategory)));
            }
        }
        else if (root.ValueKind == JsonValueKind.Array)
        {
            foreach (var item in root.EnumerateArray())
            {
                list.Add(EnsureDraftId(ParseDraft(item.GetRawText(), defaultCategory)));
            }
        }
        else if (root.TryGetProperty("name", out _))
        {
            list.Add(ParseDraft(json, defaultCategory));
        }

        return list;
    }

    private List<GeneratedIngredientDto> ParseIngredientsSync(JsonElement ingEl)
    {
        var list = new List<GeneratedIngredientDto>();
        foreach (var item in ingEl.EnumerateArray())
        {
            list.Add(new GeneratedIngredientDto
            {
                Name = item.TryGetProperty("name", out var nameEl)
                    ? AiRecipeJsonParser.GetString(nameEl) ?? ""
                    : "",
                AmountGrams = item.TryGetProperty("amountGrams", out var g)
                    ? AiRecipeJsonParser.GetNullableDecimal(g)
                    : null,
                Unit = item.TryGetProperty("unit", out var u) && u.ValueKind != JsonValueKind.Null
                    ? AiRecipeJsonParser.GetString(u)
                    : null,
                CatalogName = item.TryGetProperty("catalogName", out var c)
                    ? AiRecipeJsonParser.GetString(c)
                    : null,
            });
        }

        return list;
    }

    private async Task<List<GeneratedIngredientDto>> ParseIngredientsAsync(JsonElement ingEl, CancellationToken ct)
    {
        var list = ParseIngredientsSync(ingEl);
        foreach (var item in list)
        {
            item.CatalogItemId = await _catalogMatching.ResolveCatalogItemIdAsync(item.CatalogName ?? item.Name, ct);
            item.IsLinked = item.CatalogItemId.HasValue;
        }

        return list;
    }

    private static List<GeneratedStepDto> ParseSteps(JsonElement stepsEl)
    {
        var list = new List<GeneratedStepDto>();
        foreach (var item in stepsEl.EnumerateArray())
        {
            var stepNumber = item.TryGetProperty("stepNumber", out var stepEl)
                ? AiRecipeJsonParser.GetInt32(stepEl, list.Count + 1)
                : list.Count + 1;
            list.Add(new GeneratedStepDto
            {
                StepNumber = stepNumber,
                Description = item.TryGetProperty("description", out var descEl)
                    ? AiRecipeJsonParser.GetString(descEl) ?? ""
                    : "",
            });
        }

        return list;
    }

    private static bool IsSpiceUnit(string? unit)
    {
        if (string.IsNullOrWhiteSpace(unit)) return false;
        var u = unit.Trim().ToLowerInvariant();
        return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta";
    }

    private static RecipeGeneratorConstraintsDto DeserializeConstraints(string json) =>
        JsonSerializer.Deserialize<RecipeGeneratorConstraintsDto>(json) ?? new RecipeGeneratorConstraintsDto();

    private static List<GeneratedRecipeDraftDto> DeserializeDrafts(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) return [];

        var trimmed = json.TrimStart();
        if (trimmed.StartsWith('['))
        {
            return (JsonSerializer.Deserialize<List<GeneratedRecipeDraftDto>>(json) ?? [])
                .Select(EnsureDraftId)
                .ToList();
        }

        var single = JsonSerializer.Deserialize<GeneratedRecipeDraftDto>(json);
        return single is null ? [] : [EnsureDraftId(single)];
    }

    private static string SerializeDrafts(IReadOnlyList<GeneratedRecipeDraftDto> drafts) =>
        JsonSerializer.Serialize(drafts);

    private static GeneratedRecipeDraftDto EnsureDraftId(GeneratedRecipeDraftDto draft)
    {
        if (string.IsNullOrWhiteSpace(draft.DraftId))
        {
            draft.DraftId = Guid.NewGuid().ToString();
        }

        return draft;
    }

    private static RecipeGeneratorSessionDto MapSession(
        RecipeGeneratorSession session,
        RecipeGeneratorConstraintsDto constraints,
        IReadOnlyList<GeneratedRecipeDraftDto> drafts) => new()
    {
        Id = session.Id,
        Status = session.Status,
        Constraints = constraints,
        Draft = drafts.FirstOrDefault(),
        Drafts = drafts,
        SavedRecipeId = session.SavedRecipeId,
        GenerationVersion = session.GenerationVersion,
    };
}
