package app.mealsmadeeasy.api.recipe;

import app.mealsmadeeasy.api.MinIOTestsExtension;
import app.mealsmadeeasy.api.PostgresTestsExtension;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserRepository;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.security.access.AccessDeniedException;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.UUID;

import static app.mealsmadeeasy.api.recipe.ContainsRecipesMatcher.containsRecipes;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;

// TODO: test mainImage included
// TODO: test prep/cooking/total times included
@SpringBootTest
@ExtendWith(PostgresTestsExtension.class)
@ExtendWith(MinIOTestsExtension.class)
public class RecipeServiceTests {

    @Autowired
    private RecipeService recipeService;

    @Autowired
    private RecipeStarService recipeStarService;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private ImageService imageService;

    private User seedUser() {
        final String uuid = UUID.randomUUID().toString();
        final User draft = User.getDefaultDraft();
        draft.setUsername(uuid);
        draft.setEmail(uuid + "@test.com");
        draft.setPassword("test");
        return this.userRepository.save(draft);
    }

    private Image seedImage(User owner) throws ImageException, IOException {
        final String uuid = UUID.randomUUID().toString();
        final ImageCreateSpec spec = ImageCreateSpec
                .builder()
                .alt("Test alt")
                .caption("Test caption")
                .isPublic(true)
                .build();
        final InputStream inputStream = this.getClass().getResourceAsStream("/HAL9000.svg");
        return this.imageService.create(
                owner,
                uuid + ".svg",
                inputStream,
                27881L,
                spec
        );
    }

    private Recipe createTestRecipe(@Nullable User owner) {
        return this.createTestRecipe(owner, false);
    }

    private Recipe createTestRecipe(@Nullable User owner, boolean isPublic) {
        final RecipeCreateSpec spec = RecipeCreateSpec.builder()
                .slug(UUID.randomUUID().toString())
                .title("My Recipe")
                .rawText("Hello!")
                .isPublic(isPublic)
                .build();
        return this.recipeService.create(owner, spec, false);
    }

    @Test
    public void smokeScreen() {}

    @Test
    public void create() {
        final User user = this.seedUser();
        final Recipe recipe = this.createTestRecipe(user);
        assertThat(recipe.getOwner().getUsername(), is(user.getUsername()));
        assertThat(recipe.getTitle(), is("My Recipe"));
        assertThat(recipe.getRawText(), is("Hello!"));
    }

    @Test
    public void whenCreate_allFieldsTransferredFromSpec() throws Exception {
        final User owner = this.seedUser();
        final Image image = this.seedImage(owner);

        final List<RecipeCreateSpec.IngredientCreateSpec> ingredients = List.of(
                RecipeCreateSpec.IngredientCreateSpec.builder()
                        .amount("1T")
                        .name("Butter")
                        .notes("softened")
                        .build(),
                RecipeCreateSpec.IngredientCreateSpec.builder()
                        .amount("2T")
                        .name("Shortening")
                        .notes("vegan")
                        .build()
        );
        final RecipeCreateSpec spec = RecipeCreateSpec.builder()
                .title("Recipe Title")
                .slug("recipe-slug")
                .preparationTime(15)
                .cookingTime(30)
                .totalTime(45)
                .ingredients(ingredients)
                .rawText("# Hello Recipe")
                .isPublic(true)
                .mainImage(image)
                .build();

        final Recipe recipe = this.recipeService.create(owner, spec, false);
        assertThat(recipe.getId(), is(notNullValue()));
        assertThat(recipe.getTitle(), is("Recipe Title"));
        assertThat(recipe.getSlug(), is("recipe-slug"));
        assertThat(recipe.getPreparationTime(), is(15));
        assertThat(recipe.getCookingTime(), is(30));
        assertThat(recipe.getTotalTime(), is(45));
        assertThat(recipe.getIngredients(), is(notNullValue()));
        assertThat(recipe.getIngredients().size(), is(2));
        assertThat(recipe.getRawText(), is("# Hello Recipe"));
        assertThat(recipe.getMainImage(), is(notNullValue()));
        assertThat(recipe.getMainImage().getId(), is(image.getId()));
        assertThat(recipe.getIsPublic(), is(true));
    }

    @Test
    public void getByIdPublicNoViewerDoesNotThrow() {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        assertDoesNotThrow(() -> this.recipeService.getById(recipe.getId(), null));
    }

    @Test
    public void getByIdHasCorrectProperties() {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        final Recipe byId = this.recipeService.getById(recipe.getId(), null);
        assertThat(byId.getId(), is(recipe.getId()));
        assertThat(byId.getSlug(), is(recipe.getSlug()));
        assertThat(byId.getTitle(), is("My Recipe"));
        assertThat(byId.getRawText(), is("Hello!"));
        assertThat(byId.getIsPublic(), is(true));
    }

    @Test
    public void getByIdThrowsWhenNotPublicAndNoViewer() {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, false); // not public
        assertThrows(AccessDeniedException.class, () -> this.recipeService.getById(recipe.getId(), null));
    }

    @Test
    public void getByIdThrowsWhenNotViewer() {
        final User owner = this.seedUser();
        final User notViewer = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner);
        assertThrows(AccessDeniedException.class, () -> this.recipeService.getById(recipe.getId(), notViewer));
    }

    @Test
    public void getByIdOkayWhenPublicAndNoViewer() {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        assertDoesNotThrow(() -> this.recipeService.getById(recipe.getId(), null));
    }

    @Test
    public void getByIdOkayWhenPublicRecipeWithViewer() {
        final User owner = this.seedUser();
        final User viewer = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        assertDoesNotThrow(() -> this.recipeService.getById(recipe.getId(), viewer));
    }

    @Test
    public void getByIdOkayWithStarsPublicAndNoViewer() {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        final RecipeStar star = this.recipeStarService.create(recipe.getId(), owner.getId());
        final Recipe byIdWithStars = assertDoesNotThrow(() -> this.recipeService.getByIdWithStars(
                recipe.getId(), null
        ));
        assertThat(byIdWithStars.getStars(), ContainsRecipeStarsMatcher.containsStars(star));
    }

    @Test
    public void getByIdOkayWithStarsThrowsWhenNotViewer() {
        final User owner = this.seedUser();
        final User notViewer = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner);
        assertThrows(AccessDeniedException.class, () -> this.recipeService.getByIdWithStars(recipe.getId(), notViewer));
    }

    @Test
    public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() {
        final User owner = this.seedUser();
        final User viewer = this.seedUser();
        final Recipe notYetPublicRecipe = this.createTestRecipe(owner);
        final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(notYetPublicRecipe)
                .isPublic(true)
                .build();
        final Recipe publicRecipe = this.recipeService.update(
                notYetPublicRecipe.getOwner().getUsername(),
                notYetPublicRecipe.getSlug(),
                updateSpec,
                owner
        );
        assertDoesNotThrow(() -> this.recipeService.getByIdWithStars(publicRecipe.getId(), viewer));
    }

    @Test
    public void getByMinimumStarsAllPublic() {
        final User owner = this.seedUser();
        final User u0 = this.seedUser();
        final User u1 = this.seedUser();

        final Recipe r0 = this.createTestRecipe(owner, true);
        final Recipe r1 = this.createTestRecipe(owner, true);
        final Recipe r2 = this.createTestRecipe(owner, true);

        // r0.stars = 0, r1.stars = 1, r2.stars = 2
        this.recipeStarService.create(r1.getId(), u0.getId());
        this.recipeStarService.create(r2.getId(), u0.getId());
        this.recipeStarService.create(r2.getId(), u1.getId());

        final List<Recipe> zeroStars = this.recipeService.getByMinimumStars(0, null);
        final List<Recipe> oneStar = this.recipeService.getByMinimumStars(1, null);
        final List<Recipe> twoStars = this.recipeService.getByMinimumStars(2, null);

        assertThat(zeroStars, containsRecipes(r0, r1, r2));
        assertThat(oneStar, containsRecipes(r1, r2));
        assertThat(twoStars, containsRecipes(r2));
    }

    @Test
    public void getByMinimumStarsOnlySomeViewable() {
        final User owner = this.seedUser();
        final User u0 = this.seedUser();
        final User u1 = this.seedUser();
        final User viewer = this.seedUser();

        Recipe r0 = this.createTestRecipe(owner, false); // not public
        Recipe r1 = this.createTestRecipe(owner, false);
        Recipe r2 = this.createTestRecipe(owner, false);

        for (final User starer : List.of(u0, u1)) {
            r0 = this.recipeService.addViewer(r0.getId(), owner, starer);
            r1 = this.recipeService.addViewer(r1.getId(), owner, starer);
            r2 = this.recipeService.addViewer(r2.getId(), owner, starer);
        }

        // r0.stars = 0, r1.stars = 1, r2.stars = 2
        this.recipeStarService.create(r1.getId(), u0.getId());
        this.recipeStarService.create(r2.getId(), u0.getId());
        this.recipeStarService.create(r2.getId(), u1.getId());

        final List<Recipe> zeroStarsNoneViewable = this.recipeService.getByMinimumStars(0, viewer);
        final List<Recipe> oneStarNoneViewable = this.recipeService.getByMinimumStars(1, viewer);
        final List<Recipe> twoStarsNoneViewable = this.recipeService.getByMinimumStars(2, viewer);

        assertThat(zeroStarsNoneViewable, not(containsRecipes(r0, r1, r2)));
        assertThat(oneStarNoneViewable, not(containsRecipes(r1, r2)));
        assertThat(twoStarsNoneViewable, not(containsRecipes(r2)));

        // Now make them viewable
        r0 = this.recipeService.addViewer(r0.getId(), owner, viewer);
        r1 = this.recipeService.addViewer(r1.getId(), owner, viewer);
        r2 = this.recipeService.addViewer(r2.getId(), owner, viewer);

        final List<Recipe> zeroStarsViewable = this.recipeService.getByMinimumStars(0, viewer);
        final List<Recipe> oneStarViewable = this.recipeService.getByMinimumStars(1, viewer);
        final List<Recipe> twoStarsViewable = this.recipeService.getByMinimumStars(2, viewer);

        assertThat(zeroStarsViewable, containsRecipes(r0, r1, r2));
        assertThat(oneStarViewable, containsRecipes(r1, r2));
        assertThat(twoStarsViewable, containsRecipes(r2));
    }

    @Test
    public void getPublicRecipes() {
        final User owner = this.seedUser();

        Recipe r0 = this.createTestRecipe(owner, true);
        Recipe r1 = this.createTestRecipe(owner, true);

        final List<Recipe> publicRecipes = this.recipeService.getPublicRecipes(Pageable.unpaged()).toList();
        assertThat(publicRecipes, containsRecipes(r0, r1));
    }

    @Test
    public void getRecipeInfoViewsViewableByOwnerWhenPublicAndPrivate() {
        final User owner = this.seedUser();
        Recipe r0 = this.createTestRecipe(owner, true);
        Recipe r1 = this.createTestRecipe(owner, false);

        final Slice<Recipe> viewableInfoViewsSlice = this.recipeService.getViewableBy(Pageable.unpaged(), owner);
        final List<Recipe> viewableInfos = viewableInfoViewsSlice.toList();
        assertThat(viewableInfos, containsRecipes(r0, r1));
    }

    @Test
    public void whenUpdate_allFieldsTransferred() throws Exception {
        final User owner = this.seedUser();
        final Recipe base = this.createTestRecipe(owner);

        final List<RecipeUpdateSpec.IngredientUpdateSpec> ingredients = List.of(
                RecipeUpdateSpec.IngredientUpdateSpec.builder()
                        .amount("1T")
                        .name("Butter")
                        .notes("Softened")
                        .build(),
                RecipeUpdateSpec.IngredientUpdateSpec.builder()
                        .amount("2T")
                        .name("Shortening")
                        .notes("Vegan")
                        .build()
        );

        final Image image = this.seedImage(owner);
        final RecipeUpdateSpec.MainImageUpdateSpec mainImageSpec = RecipeUpdateSpec.MainImageUpdateSpec.builder()
                .username(image.getOwner().getUsername())
                .filename(image.getUserFilename())
                .build();

        final RecipeUpdateSpec spec = RecipeUpdateSpec.builder()
                .title("New Title")
                .preparationTime(20)
                .cookingTime(40)
                .totalTime(60)
                .ingredients(ingredients)
                .rawText("Updated text.")
                .isPublic(true)
                .mainImage(mainImageSpec)
                .build();

        final Recipe updated = this.recipeService.update(
                base.getOwner().getUsername(),
                base.getSlug(),
                spec,
                owner
        );

        assertThat(updated.getId(), is(base.getId()));
        assertThat(updated.getTitle(), is("New Title"));
        assertThat(updated.getSlug(), is(base.getSlug()));
        assertThat(updated.getPreparationTime(), is(20));
        assertThat(updated.getCookingTime(), is(40));
        assertThat(updated.getTotalTime(), is(60));
        assertThat(updated.getIngredients(), is(notNullValue()));
        assertThat(updated.getIngredients().size(), is(2));
        assertThat(updated.getIsPublic(), is(true));
        assertThat(updated.getMainImage(), is(notNullValue()));
        assertThat(updated.getMainImage().getId(), is(image.getId()));
    }

    @Test
    public void updateRawText() {
        final User owner = this.seedUser();
        final RecipeCreateSpec createSpec = RecipeCreateSpec.builder()
                .slug(UUID.randomUUID().toString())
                .title("My Recipe")
                .rawText("# A Heading")
                .build();
        Recipe recipe = this.recipeService.create(owner, createSpec, false);
        final String newRawText = "# A Heading\n## A Subheading";
        final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe)
                .rawText(newRawText)
                .build();
        recipe = this.recipeService.update(
                recipe.getOwner().getUsername(),
                recipe.getSlug(),
                updateSpec,
                owner
        );
        assertThat(recipe.getRawText(), is(newRawText));
    }

    @Test
    public void updateRawTextThrowsIfNotOwner() {
        final User owner = this.seedUser();
        final User notOwner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner);
        final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.fromRecipeToBuilder(recipe)
                .rawText("should fail")
                .build();
        assertThrows(
                AccessDeniedException.class,
                () -> this.recipeService.update(
                        recipe.getOwner().getUsername(),
                        recipe.getSlug(),
                        updateSpec,
                        notOwner
                )
        );
    }

    @Test
    public void deleteRecipe() {
        final User owner = this.seedUser();
        final Recipe toDelete = this.createTestRecipe(owner);
        this.recipeService.deleteRecipe(toDelete.getId(), owner);
        assertThrows(NoSuchEntityWithIdException.class, () -> this.recipeService.getById(toDelete.getId(), owner));
    }

    @Test
    public void deleteRecipeThrowsIfNotOwner() {
        final User owner = this.seedUser();
        final User notOwner = this.seedUser();
        final Recipe toDelete = this.createTestRecipe(owner);
        assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(toDelete.getId(), notOwner));
    }

    @Test
    public void deleteRecipeByUsernameAndSlug() {
        final User owner = this.seedUser();
        final Recipe toDelete = this.createTestRecipe(owner);
        this.recipeService.deleteRecipe(owner.getUsername(), toDelete.getSlug(), owner);
        assertThrows(NoSuchEntityWithIdException.class, () -> this.recipeService.getById(toDelete.getId(), owner));
    }

    @Test
    public void deleteRecipeByUsernameAndSlug_throwsIfNullDeleter() {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner);
        assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(
                owner.getUsername(), recipe.getSlug(), null
        ));
    }

    @Test
    public void deleteRecipeByUsernameAndSlug_throwsIfNotOwner() {
        final User owner = this.seedUser();
        final User wrong = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner);
        assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(
                owner.getUsername(), recipe.getSlug(), wrong
        ));
    }

    @Test
    public void createDraftReturnsDefaultRecipeDraft() {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        assertThat(recipeDraft.getCreated(), is(notNullValue()));
        assertThat(recipeDraft.getState(), is(RecipeDraft.State.ENTER_DATA));
        assertThat(recipeDraft.getOwner(), is(owner));
    }

}
