package app.mealsmadeeasy.api.recipe;

import app.mealsmadeeasy.api.file.File;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.job.JobService;
import app.mealsmadeeasy.api.markdown.MarkdownService;
import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody;
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler;
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobPayload;
import app.mealsmadeeasy.api.recipe.job.RecipeSummaryJobHandler;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import app.mealsmadeeasy.api.util.NoSuchEntityWithUsernameAndSlugException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.Nullable;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class RecipeService {

    private final RecipeRepository recipeRepository;
    private final RecipeStarRepository recipeStarRepository;
    private final ImageService imageService;
    private final MarkdownService markdownService;
    private final EmbeddingModel embeddingModel;
    private final RecipeDraftRepository recipeDraftRepository;
    private final JobService jobService;

    public Recipe create(User owner, RecipeCreateSpec spec, boolean queueSummaryJob) {
        final Recipe draft = new Recipe();
        draft.setOwner(owner);
        draft.setSlug(spec.getSlug());
        draft.setTitle(spec.getTitle());

        if (spec.getIngredients() != null) {
            draft.setIngredients(spec.getIngredients().stream()
                    .map(ingredientSpec -> {
                        final Recipe.Ingredient ingredient = new Recipe.Ingredient();
                        ingredient.setAmount(ingredientSpec.amount());
                        ingredient.setName(ingredientSpec.name());
                        ingredient.setNotes(ingredientSpec.notes());
                        return ingredient;
                    })
                    .toList()
            );
        }

        draft.setRawText(spec.getRawText());
        draft.setMainImage(spec.getMainImage());
        draft.setIsPublic(spec.isPublic());
        draft.setPreparationTime(spec.getPreparationTime());
        draft.setCookingTime(spec.getCookingTime());
        draft.setTotalTime(spec.getTotalTime());
        final Recipe saved = this.recipeRepository.save(draft);

        if (queueSummaryJob) {
            this.jobService.create(
                    RecipeSummaryJobHandler.JOB_KEY,
                    new RecipeSummaryJobHandler.RecipeSummaryJobPayload(saved.getId())
            );
        }

        return saved;
    }

    public Recipe create(User owner, RecipeCreateSpec spec) {
        return this.create(owner, spec, false);
    }

    private Recipe getById(Integer id) {
        return this.recipeRepository.findById(id).orElseThrow(() -> new NoSuchEntityWithIdException(Recipe.class, id));
    }

    private Recipe getByUsernameAndSlug(String username, String slug) {
        return this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(
                () -> new NoSuchEntityWithUsernameAndSlugException(Recipe.class, username, slug)
        );
    }

    @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
    public Recipe getById(Integer id, @Nullable User viewer) {
        return this.getById(id);
    }

    @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
    public Recipe getByIdWithStars(Integer id, @Nullable User viewer) {
        return this.recipeRepository.findByIdWithStars(id).orElseThrow(
                () -> new NoSuchEntityWithIdException(Recipe.class, id)
        );
    }

    @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)")
    public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) {
        return this.getByUsernameAndSlug(username, slug);
    }

    public String getRenderedMarkdown(Recipe entity) {
        if (entity.getCachedRenderedText() == null) {
            entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText()));
            entity = this.recipeRepository.save(entity);
        }
        return entity.getCachedRenderedText();
    }

    public int getStarCount(Recipe recipe) {
        return this.recipeRepository.getStarCount(recipe.getId());
    }

    public int getViewerCount(Recipe recipe) {
        return this.recipeRepository.getViewerCount(recipe.getId());
    }

    public Slice<Recipe> getViewableBy(Pageable pageable, User viewer) {
        return this.recipeRepository.findAllViewableBy(viewer, pageable);
    }

    public int countViewableBy(User viewer) {
        return this.recipeRepository.countViewableBy(viewer);
    }

    public List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer) {
        return List.copyOf(
                this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, viewer)
        );
    }

    public Slice<Recipe> getPublicRecipes(Pageable pageable) {
        return this.recipeRepository.findAllByIsPublicIsTrue(pageable);
    }

    public int countPublicRecipes() {
        return this.recipeRepository.countByIsPublicIsTrue();
    }

    public List<Recipe> aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) {
        final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
        final List<Recipe> results;
        if (viewer == null) {
            results = this.recipeRepository.searchByEmbeddingAndIsPublic(queryEmbedding, 0.5f);
        } else {
            results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId());
        }
        return results;
    }

    private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) {
        if (spec.getTitle() != null) {
            recipe.setTitle(spec.getTitle());
        }
        if (spec.getPreparationTime() != null) {
            recipe.setPreparationTime(spec.getPreparationTime());
        }
        if (spec.getCookingTime() != null) {
            recipe.setCookingTime(spec.getCookingTime());
        }
        if (spec.getTotalTime() != null) {
            recipe.setTotalTime(spec.getTotalTime());
        }

        if (spec.getIngredients() != null) {
            recipe.setIngredients(spec.getIngredients().stream()
                    .map(ingredientSpec -> {
                        final Recipe.Ingredient ingredient = new Recipe.Ingredient();
                        ingredient.setAmount(ingredientSpec.amount());
                        ingredient.setName(ingredientSpec.name());
                        ingredient.setNotes(ingredientSpec.notes());
                        return ingredient;
                    })
                    .toList()
            );
        }

        if (spec.getRawText() != null) {
            recipe.setRawText(spec.getRawText());
            recipe.setCachedRenderedText(null);
        }

        if (spec.getIsPublic() != null) {
            recipe.setIsPublic(spec.getIsPublic());
        }

        // TODO: we have to think about how to unset the main image vs. just leaving it out of the request
        if (spec.getMainImage() != null) {
            final Image mainImage = this.imageService.getByUsernameAndFilename(
                    spec.getMainImage().getUsername(),
                    spec.getMainImage().getFilename(),
                    modifier
            );
            recipe.setMainImage(mainImage);
        }
    }

    @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)")
    public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier) {
        final Recipe recipe = this.getByUsernameAndSlug(username, slug);
        this.prepareForUpdate(spec, recipe, modifier);
        return this.recipeRepository.save(recipe);
    }

    @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
    public Recipe addViewer(Integer id, User modifier, User viewer) {
        final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow(
                () -> new NoSuchEntityWithIdException(Recipe.class, id)
        );
        final Set<User> viewers = new HashSet<>(entity.getViewers());
        viewers.add(viewer);
        entity.setViewers(viewers);
        return this.recipeRepository.save(entity);
    }

    @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
    public Recipe removeViewer(Integer id, User modifier, User viewer) {
        final Recipe entity = this.getById(id);
        final Set<User> viewers = new HashSet<>(entity.getViewers());
        viewers.remove(viewer);
        entity.setViewers(viewers);
        return this.recipeRepository.save(entity);
    }

    @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
    public Recipe clearAllViewers(Integer id, User modifier) {
        final Recipe entity = this.getById(id);
        entity.setViewers(new HashSet<>());
        return this.recipeRepository.save(entity);
    }

    @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
    public void deleteRecipe(Integer id, User modifier) {
        this.recipeRepository.deleteById(id);
    }

    @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #deleter)")
    @Transactional
    public void deleteRecipe(String username, String slug, User deleter) {
        this.recipeRepository.deleteRecipeByOwnerUsernameAndSlug(username, slug);
    }

    @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
    @Contract("_, _, null -> null")
    public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) {
        if (viewer == null) {
            return null;
        }
        return this.recipeStarRepository.isStarer(username, slug, viewer.getId());
    }

    @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
    @Contract("_, _, null -> null")
    public @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer) {
        if (viewer == null) {
            return null;
        }
        return viewer.getUsername().equals(username);
    }

    public List<RecipeDraft> getDrafts(User viewer) {
        return this.recipeDraftRepository.findAllViewableBy(viewer);
    }

    public RecipeDraft createDraft(User owner) {
        final var recipeDraft = new RecipeDraft();
        recipeDraft.setState(RecipeDraft.State.ENTER_DATA);
        recipeDraft.setOwner(owner);
        return this.recipeDraftRepository.save(recipeDraft);
    }

    @Transactional
    public RecipeDraft createAiDraft(File sourceFile, User owner) {
        final var recipeDraft = new RecipeDraft();
        recipeDraft.setState(RecipeDraft.State.INFER);
        recipeDraft.setOwner(owner);

        final var saved = this.recipeDraftRepository.save(recipeDraft);

        this.jobService.create(
                RecipeInferJobHandler.JOB_KEY,
                new RecipeInferJobPayload(saved.getId(), sourceFile.getId()),
                1,
                OffsetDateTime.now()
        );

        return saved;
    }

    public RecipeDraft getDraftById(UUID id) {
        return this.recipeDraftRepository.findById(id).orElseThrow(
                () -> new NoSuchEntityWithIdException(RecipeDraft.class, id)
        );
    }

    @PostAuthorize("@recipeDraftSecurity.isViewableBy(returnObject, #viewer)")
    public RecipeDraft getDraftByIdWithViewer(UUID id, @Nullable User viewer) {
        return this.recipeDraftRepository.findById(id)
                .orElseThrow(() -> new NoSuchEntityWithIdException(RecipeDraft.class, id));
    }

    public RecipeDraft saveDraft(RecipeDraft recipeDraft) {
        return this.recipeDraftRepository.save(recipeDraft);
    }

    @PreAuthorize("@recipeDraftSecurity.isUpdatableBy(#id, #modifier)")
    public RecipeDraft updateDraft(UUID id, RecipeDraftUpdateSpec spec, User modifier) {
        final RecipeDraft recipeDraft = this.recipeDraftRepository.findById(id)
                .orElseThrow(() -> new NoSuchEntityWithIdException(RecipeDraft.class, id));
        if (spec.slug() != null) {
            recipeDraft.setSlug(spec.slug());
        }
        if (spec.title() != null) {
            recipeDraft.setTitle(spec.title());
        }
        if (spec.preparationTime() != null) {
            recipeDraft.setPreparationTime(spec.preparationTime());
        }
        if (spec.cookingTime() != null) {
            recipeDraft.setCookingTime(spec.cookingTime());
        }
        if (spec.totalTime() != null) {
            recipeDraft.setTotalTime(spec.totalTime());
        }
        if (spec.rawText() != null) {
            recipeDraft.setRawText(spec.rawText());
        }
        if (spec.ingredients() != null) {
            final List<RecipeDraft.IngredientDraft> ingredients = spec.ingredients()
                    .stream()
                    .map(ingredientSpec -> {
                        final var ingredient = new RecipeDraft.IngredientDraft();
                        ingredient.setAmount(ingredientSpec.amount());
                        ingredient.setName(ingredientSpec.name());
                        ingredient.setNotes(ingredientSpec.notes());
                        return ingredient;
                    })
                    .toList();
            recipeDraft.setIngredients(ingredients);
        }
        if (spec.mainImage() != null) {
            recipeDraft.setMainImage(spec.mainImage());
        }
        return this.recipeDraftRepository.save(recipeDraft);
    }

    @PreAuthorize("@recipeDraftSecurity.isUpdatableBy(#id, #modifier)")
    public void deleteDraft(UUID id, User modifier) {
        this.recipeDraftRepository.deleteById(id);
    }

    @PreAuthorize("@recipeDraftSecurity.isUpdatableBy(#draftId, #modifier)")
    @Transactional
    public Recipe publishDraft(UUID draftId, User modifier) {
        final RecipeDraft recipeDraft = this.recipeDraftRepository.findById(draftId)
                .orElseThrow(() -> new NoSuchEntityWithIdException(RecipeDraft.class, draftId));
        final var b = RecipeCreateSpec.builder()
                .slug(recipeDraft.getSlug())
                .title(recipeDraft.getTitle())
                .preparationTime(recipeDraft.getPreparationTime())
                .cookingTime(recipeDraft.getCookingTime())
                .totalTime(recipeDraft.getTotalTime())
                .rawText(recipeDraft.getRawText())
                .isPublic(false)
                .mainImage(recipeDraft.getMainImage());

        if (recipeDraft.getIngredients() != null) {
            b.ingredients(recipeDraft.getIngredients().stream()
                    .map(ingredientDraft -> RecipeCreateSpec.IngredientCreateSpec.builder()
                            .amount(ingredientDraft.getAmount())
                            .name(ingredientDraft.getName())
                            .notes(ingredientDraft.getNotes())
                            .build()
                    )
                    .toList()
            );
        }

        final RecipeCreateSpec spec = b.build();
        final Recipe recipe = this.create(recipeDraft.getOwner(), spec, true);
        this.recipeDraftRepository.deleteById(draftId); // delete old draft
        return recipe;
    }

}
