package app.mealsmadeeasy.api.recipe;

import app.mealsmadeeasy.api.MinIOTestsExtension;
import app.mealsmadeeasy.api.PostgresTestsExtension;
import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginDetails;
import app.mealsmadeeasy.api.auth.LoginException;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.S3ImageServiceTests;
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.RecipeStarService;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

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

import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(PostgresTestsExtension.class)
@ExtendWith(MinIOTestsExtension.class)
public class RecipesControllerTests {

    private static InputStream getHal9000() {
        return S3ImageServiceTests.class.getClassLoader().getResourceAsStream("HAL9000.svg");
    }

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private RecipeService recipeService;

    @Autowired
    private RecipeStarService recipeStarService;

    @Autowired
    private UserService userService;

    @Autowired
    private AuthService authService;

    @Autowired
    private ImageService imageService;

    @Autowired
    private ObjectMapper objectMapper;

    private static final String TEST_PASSWORD = "test";

    private User seedUser() {
        final String uuid = UUID.randomUUID().toString();
        try {
            return this.userService.createUser(uuid, uuid + "@test.com", TEST_PASSWORD);
        } catch (UserCreateException e) {
            throw new RuntimeException(e);
        }
    }

    private Recipe createTestRecipe(User owner, boolean isPublic) {
        final RecipeCreateSpec spec = RecipeCreateSpec.builder()
                .slug(UUID.randomUUID().toString())
                .title("Test Recipe")
                .preparationTime(10)
                .cookingTime(20)
                .totalTime(30)
                .rawText("# Hello, World!")
                .isPublic(isPublic)
                .build();
        return this.recipeService.create(owner, spec, false);
    }

    private String getAccessToken(User user) throws LoginException {
        return this.authService.login(user.getUsername(), TEST_PASSWORD)
                .getAccessToken()
                .getToken();
    }

    private Image createHal9000(User owner) {
        try (final InputStream hal9000 = getHal9000()) {
            return this.imageService.create(
                    owner,
                    UUID.randomUUID() + ".svg",
                    hal9000,
                    27881L,
                    ImageCreateSpec.builder().build()
            );
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Test
    public void getRecipePageViewByIdPublicRecipeNoPrincipal() throws Exception {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        this.mockMvc.perform(
                get("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.recipe.id").value(recipe.getId()))
                .andExpect(jsonPath("$.recipe.created").exists()) // TODO: better matching of exact LocalDateTime
                .andExpect(jsonPath("$.recipe.modified").doesNotExist())
                .andExpect(jsonPath("$.recipe.slug").value(recipe.getSlug()))
                .andExpect(jsonPath("$.recipe.title").value("Test Recipe"))
                .andExpect(jsonPath("$.recipe.preparationTime").value(recipe.getPreparationTime()))
                .andExpect(jsonPath("$.recipe.cookingTime").value(recipe.getCookingTime()))
                .andExpect(jsonPath("$.recipe.totalTime").value(recipe.getTotalTime()))
                .andExpect(jsonPath("$.recipe.text").value("<h1>Hello, World!</h1>"))
                .andExpect(jsonPath("$.recipe.rawText").doesNotExist())
                .andExpect(jsonPath("$.recipe.owner.id").value(owner.getId()))
                .andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
                .andExpect(jsonPath("$.recipe.starCount").value(0))
                .andExpect(jsonPath("$.recipe.viewerCount").value(0))
                .andExpect(jsonPath("$.recipe.public").value(true))
                .andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
                .andExpect(jsonPath("$.isStarred").value(nullValue()))
                .andExpect(jsonPath("$.isOwner").value(nullValue()));
    }

    @Test
    public void getFullRecipeViewIncludeRawText() throws Exception {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        this.mockMvc.perform(
                get(
                        "/recipes/{username}/{slug}?includeRawText=true",
                        recipe.getOwner().getUsername(),
                        recipe.getSlug()
                )
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.recipe.rawText").value(recipe.getRawText()));
    }

    @Test
    public void getFullRecipeViewPrincipalIsStarer() throws Exception {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, false);
        this.recipeStarService.create(recipe.getId(), owner.getId());
        final String accessToken = this.getAccessToken(owner);
        this.mockMvc.perform(
                get("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.isStarred").value(true));
    }

    @Test
    public void getFullRecipeViewPrincipalIsNotStarer() throws Exception {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, false);
        final String accessToken = this.getAccessToken(owner);
        this.mockMvc.perform(
                get("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.isStarred").value(false));
    }

    @Test
    public void getRecipeInfoViewsNoPrincipal() throws Exception {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        this.mockMvc.perform(get("/recipes"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.slice.number").value(0))
                .andExpect(jsonPath("$.slice.size").value(20))
                .andExpect(jsonPath("$.content").isArray())
                .andExpect(jsonPath("$.content[*].id").value(hasItem(recipe.getId())));
    }

    @Test
    public void getRecipeInfoViewsWithPrincipalIncludesPrivate() throws Exception {
        final User owner = this.seedUser();
        final Recipe r0 = this.createTestRecipe(owner, true);
        final Recipe r1 = this.createTestRecipe(owner, true);
        final Recipe r2 = this.createTestRecipe(owner, false);
        final LoginDetails loginDetails = this.authService.login(owner.getUsername(), "test");
        this.mockMvc.perform(
                get("/recipes")
                        .header("Authorization", "Bearer " + loginDetails.getAccessToken().getToken())
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.slice.number").value(0))
                .andExpect(jsonPath("$.slice.size").value(20))
                .andExpect(jsonPath("$.content").isArray())
                .andExpect(jsonPath("$.content[*].id").value(hasItems(r0.getId(), r1.getId(), r2.getId())));
    }

    private String getUpdateBody() throws JsonProcessingException {
        final RecipeUpdateSpec spec = RecipeUpdateSpec.builder()
                .title("Updated Test Recipe")
                .preparationTime(15)
                .cookingTime(30)
                .totalTime(45)
                .rawText("# Hello, Updated World!")
                .isPublic(true)
                .build();
        return this.objectMapper.writeValueAsString(spec);
    }

    @Test
    public void updateRecipe() throws Exception {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, false);
        final Image image = this.createHal9000(owner);

        final RecipeUpdateSpec spec = RecipeUpdateSpec.builder()
                .title("Updated Test Recipe")
                .preparationTime(15)
                .cookingTime(30)
                .totalTime(45)
                .ingredients(List.of(
                        RecipeUpdateSpec.IngredientUpdateSpec.builder()
                                .amount("1T")
                                .name("Butter")
                                .notes("Softened")
                                .build(),
                        RecipeUpdateSpec.IngredientUpdateSpec.builder()
                                .amount("2T")
                                .name("Shortening")
                                .notes("Vegan")
                                .build()
                ))
                .rawText("# Hello, Updated World!")
                .isPublic(true)
                .mainImage(RecipeUpdateSpec.MainImageUpdateSpec.builder()
                        .username(image.getOwner().getUsername())
                        .filename(image.getUserFilename())
                        .build())
                .build();
        final String updateBody = this.objectMapper.writeValueAsString(spec);

        this.mockMvc.perform(
                put("/recipes/{username}/{slug}", owner.getUsername(), recipe.getSlug())
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(updateBody)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.recipe.id").value(recipe.getId()))
                .andExpect(jsonPath("$.recipe.title").value("Updated Test Recipe"))
                .andExpect(jsonPath("$.recipe.preparationTime").value(15))
                .andExpect(jsonPath("$.recipe.cookingTime").value(30))
                .andExpect(jsonPath("$.recipe.totalTime").value(45))

                .andExpect(jsonPath("$.recipe.ingredients").isArray())
                .andExpect(jsonPath("$.recipe.ingredients[0].amount").value("1T"))
                .andExpect(jsonPath("$.recipe.ingredients[0].name").value("Butter"))
                .andExpect(jsonPath("$.recipe.ingredients[0].notes").value("Softened"))
                .andExpect(jsonPath("$.recipe.ingredients[1].amount").value("2T"))
                .andExpect(jsonPath("$.recipe.ingredients[1].name").value("Shortening"))
                .andExpect(jsonPath("$.recipe.ingredients[1].notes").value("Vegan"))

                .andExpect(jsonPath("$.recipe.text").value("<h1>Hello, Updated World!</h1>"))
                .andExpect(jsonPath("$.recipe.rawText").value("# Hello, Updated World!"))
                .andExpect(jsonPath("$.recipe.owner.id").value(owner.getId()))
                .andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
                .andExpect(jsonPath("$.recipe.starCount").value(0))
                .andExpect(jsonPath("$.recipe.viewerCount").value(0))
                .andExpect(jsonPath("$.recipe.public").value(true))

                .andExpect(jsonPath("$.recipe.mainImage.owner.username").value(owner.getUsername()))
                .andExpect(jsonPath("$.recipe.mainImage.filename").value(image.getUserFilename()))

                .andExpect(jsonPath("$.isStarred").value(false))
                .andExpect(jsonPath("$.isOwner").value(true));
    }

    @Test
    public void updateRecipeReturnsViewWithMainImage() throws Exception {
        final User owner = this.seedUser();

        final Image hal9000 = this.createHal9000(owner);

        final RecipeCreateSpec createSpec = RecipeCreateSpec.builder()
                .title("Test Recipe")
                .slug(UUID.randomUUID().toString())
                .isPublic(false)
                .rawText("# Hello, World!")
                .mainImage(hal9000)
                .build();
        Recipe recipe = this.recipeService.create(owner, createSpec, false);

        final RecipeUpdateSpec updateSpec = RecipeUpdateSpec.builder()
                .title("Updated Test Recipe")
                .rawText("# Hello, Updated World!")
                .mainImage(
                        RecipeUpdateSpec.MainImageUpdateSpec.builder()
                                .username(hal9000.getOwner().getUsername())
                                .filename(hal9000.getUserFilename())
                                .build()
                )
                .build();
        final String body = this.objectMapper.writeValueAsString(updateSpec);

        final String accessToken = this.getAccessToken(owner);
        this.mockMvc.perform(
                put("/recipes/{username}/{slug}", owner.getUsername(), recipe.getSlug())
                        .header("Authorization", "Bearer " + accessToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(body)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.recipe.mainImage").isMap());
    }

    @Test
    public void addStarToRecipe() throws Exception {
        final User owner = this.seedUser();
        final User starer = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        this.mockMvc.perform(
                post("/recipes/{username}/{slug}/star", recipe.getOwner().getUsername(), recipe.getSlug())
                        .header("Authorization", "Bearer " + this.getAccessToken(starer))
        )
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.timestamp").exists());
    }

    @Test
    public void getStarForRecipe() throws Exception {
        final User owner = this.seedUser();
        final User starer = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        this.recipeStarService.create(recipe.getId(), starer.getId());
        this.mockMvc.perform(
                get("/recipes/{username}/{slug}/star", recipe.getOwner().getUsername(), recipe.getSlug())
                        .header("Authorization", "Bearer " + this.getAccessToken(starer))
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.isStarred").value(true))
                .andExpect(jsonPath("$.star").isMap())
                .andExpect(jsonPath("$.star.timestamp").exists());
    }

    @Test
    public void deleteStarFromRecipe() throws Exception {
        final User owner = this.seedUser();
        final User starer = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        this.recipeStarService.create(recipe.getId(), starer.getId());
        this.mockMvc.perform(
                delete("/recipes/{username}/{slug}/star", recipe.getOwner().getUsername(), recipe.getSlug())
                        .header("Authorization", "Bearer " + this.getAccessToken(starer))
        )
                .andExpect(status().isNoContent());
    }

    @Test
    public void deleteRecipeByOwner_returnsNoContent() throws Exception {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, false);
        this.mockMvc.perform(
                delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isNoContent());
    }

    @Test
    public void deletePrivateRecipe_noAuth_returnsUnauthorized() throws Exception {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, false);
        this.mockMvc.perform(
                delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
        )
                .andExpect(status().isUnauthorized());
    }

    @Test
    public void deletePublicRecipe_noAuth_returnsUnauthorized() throws Exception {
        final User owner = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        this.mockMvc.perform(
                delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
        )
                .andExpect(status().isUnauthorized());
    }

    @Test
    public void deletePublicRecipe_wrongAuth_returnsForbidden() throws Exception {
        final User owner = this.seedUser();
        final User wrong = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, true);
        this.mockMvc.perform(
                delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
                        .header("Authorization", "Bearer " + this.getAccessToken(wrong))
        )
                .andExpect(status().isForbidden());
    }

    @Test
    public void deletePrivateRecipe_wrongAuth_returnsForbidden() throws Exception {
        final User owner = this.seedUser();
        final User wrong = this.seedUser();
        final Recipe recipe = this.createTestRecipe(owner, false);
        this.mockMvc.perform(
                        delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug())
                                .header("Authorization", "Bearer " + this.getAccessToken(wrong))
                )
                .andExpect(status().isForbidden());
    }

}
