package app.mealsmadeeasy.api.recipe;

import app.mealsmadeeasy.api.PostgresTestsExtension;
import app.mealsmadeeasy.api.ai.InferenceService;
import app.mealsmadeeasy.api.ai.OcrService;
import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginException;
import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody;
import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler;
import app.mealsmadeeasy.api.recipe.view.RecipeDraftView;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
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.mock.web.MockMultipartFile;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.util.MimeTypeUtils;

import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

import static org.awaitility.Awaitility.await;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.hasSize;
import static org.junit.Assert.assertThrows;
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
@ExtendWith(PostgresTestsExtension.class)
@AutoConfigureMockMvc
public class RecipeDraftsControllerIntegrationTests {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private UserService userService;

    @Autowired
    private AuthService authService;

    @Autowired
    private RecipeService recipeService;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private OcrService ocrService;

    @MockitoBean
    private InferenceService inferenceService;

    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 String getAccessToken(User user) throws LoginException {
        return this.authService.login(user.getUsername(), TEST_PASSWORD)
                .getAccessToken()
                .getToken();
    }

    @Test
    public void whenNoDraft_getReturnsNotFound() throws Exception {
        final User user = this.seedUser();
        final String fakeId = UUID.randomUUID().toString();
        this.mockMvc.perform(
                get("/recipe-drafts/{fakeId}", fakeId)
                        .header("Authorization", "Bearer " + this.getAccessToken(user))
        )
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.entityName", is("RecipeDraft")))
                .andExpect(jsonPath("$.id", is(fakeId)))
                .andExpect(jsonPath("$.message", is(notNullValue())));
    }

    @Test
    public void whenNoDraftAndNoPrincipal_getReturnsUnauthorized() throws Exception {
        this.mockMvc.perform(
                get("/recipe-drafts/{fakeId}", UUID.randomUUID().toString())
        ).andExpect(status().isUnauthorized());
    }

    @Test
    public void whenDraftExists_returnsDraftById() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft seeded = this.recipeService.createDraft(owner);
        this.mockMvc.perform(
                get("/recipe-drafts/{id}", seeded.getId())
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id", is(seeded.getId().toString())))
                .andExpect(jsonPath("$.created", is(notNullValue())))
                .andExpect(jsonPath("$.modified", is(nullValue())))
                .andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString())))
                .andExpect(jsonPath("$.title", is(nullValue())))
                .andExpect(jsonPath("$.preparationTime", is(nullValue())))
                .andExpect(jsonPath("$.cookingTime", is(nullValue())))
                .andExpect(jsonPath("$.ingredients", is(nullValue())))
                .andExpect(jsonPath("$.rawText", is(nullValue())))
                .andExpect(jsonPath("$.owner.username", is(owner.getUsername())))
                .andExpect(jsonPath("$.mainImage", is(nullValue())));
    }

    @Test
    public void whenDraftExistsButNoPrincipal_returnsUnauthorized() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        this.mockMvc.perform(
                get("/recipe-drafts/{id}", recipeDraft.getId())
        ).andExpect(status().isUnauthorized());
    }

    @Test
    public void whenDraftExistsButWrongPrincipal_returnsForbidden() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        final User wrongViewer = this.seedUser();
        this.mockMvc.perform(
                get("/recipe-drafts/{id}", recipeDraft.getId())
                        .header("Authorization", "Bearer " + this.getAccessToken(wrongViewer))
        ).andExpect(status().isForbidden());
    }

    @Test
    public void whenDraftsExist_returnDrafts() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft seed0 = this.recipeService.createDraft(owner);
        final RecipeDraft seed1 = this.recipeService.createDraft(owner);
        this.mockMvc.perform(
                get("/recipe-drafts")
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").isArray())
                .andExpect(jsonPath("$", hasSize(2)));
    }

    @Test
    public void whenDraftsExistButWrongPrincipal_returnsNoDrafts() throws Exception {
        final User owner = this.seedUser();
        this.recipeService.createDraft(owner); // seed 1
        this.recipeService.createDraft(owner); // seed 2
        final User wrongViewer = this.seedUser();
        this.mockMvc.perform(
                get("/recipe-drafts")
                        .header("Authorization", "Bearer " + this.getAccessToken(wrongViewer))
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").isArray())
                .andExpect(jsonPath("$", hasSize(0)));
    }

    @Test
    public void manualDraft_returnsDraft() throws Exception {
        final User owner = this.seedUser();
        this.mockMvc.perform(
                post("/recipe-drafts/manual")
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.created", is(notNullValue())))
                .andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString())))
                .andExpect(jsonPath("$.owner.id", is(owner.getId())));
    }

    @Nested
    public class AiDraftTestsWithMocks {

        @Test
        public void whenAiDraft_returnsDraft() throws Exception {
            final User owner = seedUser();
            final MockMultipartFile sourceFile = new MockMultipartFile(
                    "sourceFile",
                    "recipe.jpeg",
                    MimeTypeUtils.IMAGE_JPEG_VALUE,
                    this.getClass().getResourceAsStream("/recipe.jpeg")
            );
            mockMvc.perform(
                    multipart("/recipe-drafts/ai")
                            .file(sourceFile)
                            .param("sourceFileName", sourceFile.getOriginalFilename())
                            .header("Authorization", "Bearer " + getAccessToken(owner))
            )
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.created", is(notNullValue())))
                    .andExpect(jsonPath("$.state", is(RecipeDraft.State.INFER.toString())))
                    .andExpect(jsonPath("$.owner.id", is(owner.getId())))
                    .andExpect(jsonPath("$.owner.username", is(owner.getUsername())));
        }

        @Test
        public void pollingAfterAiDraft_returnsDraftWithInference() throws Exception {
            Mockito.when(ocrService.readMarkdown(Mockito.any())).thenReturn("# Recipe");
            Mockito.when(inferenceService.extract(
                    Mockito.any(),
                    Mockito.any(),
                    Mockito.any()
            )).thenReturn(new RecipeInferJobHandler.RecipeExtraction("Recipe", List.of()));

            final User owner = seedUser();
            final MockMultipartFile sourceFile = new MockMultipartFile(
                    "sourceFile",
                    "recipe.jpeg",
                    MimeTypeUtils.IMAGE_JPEG_VALUE,
                    this.getClass().getResourceAsStream("/recipe.jpeg")
            );
            final MvcResult result = mockMvc.perform(
                    multipart("/recipe-drafts/ai")
                            .file(sourceFile)
                            .param("sourceFileName", sourceFile.getOriginalFilename())
                            .header("Authorization", "Bearer " + getAccessToken(owner))
            ).andReturn();
            final UUID draftId = objectMapper.readValue(
                    result.getResponse().getContentAsString(),
                    RecipeDraftView.class
            ).id();
            await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> {
                mockMvc.perform(
                        get("/recipe-drafts/{id}", draftId)
                                .header("Authorization", "Bearer " + getAccessToken(owner))
                )
                        .andExpect(status().isOk())
                        .andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString())))
                        .andExpect(jsonPath("$.lastInference", is(notNullValue())))
                        .andExpect(jsonPath("$.lastInference.inferredAt", is(notNullValue())))
                        .andExpect(jsonPath("$.lastInference.title", is("Recipe")))
                        .andExpect(jsonPath("$.lastInference.rawText", is("# Recipe")));
            });
        }

    }

    private static RecipeDraftUpdateBody getTestRecipeDraftUpdateBody() {
        return RecipeDraftUpdateBody.builder()
                .title("Test Title")
                .slug("test-slug")
                .preparationTime(15)
                .cookingTime(30)
                .totalTime(45)
                .rawText("Hello, World!")
                .ingredients(List.of(
                        RecipeDraftUpdateBody.IngredientDraftUpdateBody.builder()
                                .amount("1 Unit")
                                .name("Test Ingredient")
                                .notes("Separated")
                                .build()
                ))
                .build();
    }

    @Test
    public void whenUpdate_returnsUpdated() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        final RecipeDraftUpdateBody updateBody = getTestRecipeDraftUpdateBody();
        this.mockMvc.perform(
                put("/recipe-drafts/{id}", recipeDraft.getId())
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
                        .header("Content-Type", "application/json")
                        .content(this.objectMapper.writeValueAsString(updateBody))
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title", is("Test Title")))
                .andExpect(jsonPath("$.slug", is("test-slug")))
                .andExpect(jsonPath("$.preparationTime", is(15)))
                .andExpect(jsonPath("$.cookingTime", is(30)))
                .andExpect(jsonPath("$.totalTime", is(45)))
                .andExpect(jsonPath("$.rawText", is("Hello, World!")))
                .andExpect(jsonPath("$.ingredients", hasSize(1)))
                .andExpect(jsonPath("$.ingredients[0].amount", is("1 Unit")))
                .andExpect(jsonPath("$.ingredients[0].name", is("Test Ingredient")))
                .andExpect(jsonPath("$.ingredients[0].notes", is("Separated")));
    }

    @Test
    public void whenUpdateNoPrincipal_returnsUnauthorized() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        final RecipeDraftUpdateBody updateBody = getTestRecipeDraftUpdateBody();
        this.mockMvc.perform(
                put("/recipe-drafts/{id}", recipeDraft.getId())
                        .header("Content-Type", "application/json")
                        .content(this.objectMapper.writeValueAsString(updateBody))
        ).andExpect(status().isUnauthorized());
    }

    @Test
    public void whenWrongPrincipalUpdate_returnsForbidden() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        final RecipeDraftUpdateBody updateBody = getTestRecipeDraftUpdateBody();
        final User wrongModifier = this.seedUser();
        this.mockMvc.perform(
                put("/recipe-drafts/{id}", recipeDraft.getId())
                        .header("Authorization", "Bearer " + this.getAccessToken(wrongModifier))
                        .header("Content-Type", "application/json")
                        .content(this.objectMapper.writeValueAsString(updateBody))
        ).andExpect(status().isForbidden());
    }

    @Test
    public void whenDelete_draftIsDeleted() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        this.mockMvc.perform(
                delete("/recipe-drafts/{id}", recipeDraft.getId())
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isNoContent());
        assertThrows(NoSuchEntityWithIdException.class, () -> this.recipeService.getDraftById(recipeDraft.getId()));
    }

    @Test
    public void whenDeleteWithNoPrincipal_returnsUnauthorized() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        this.mockMvc.perform(
                delete("/recipe-drafts/{id}", recipeDraft.getId())
        ).andExpect(status().isUnauthorized());
    }

    @Test
    public void whenPublish_expectDraftDeletedAndRecipeCreated() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        recipeDraft.setTitle("Test Title");
        recipeDraft.setSlug("test-slug");
        recipeDraft.setRawText("# Hello, World!");
        this.recipeService.saveDraft(recipeDraft);
        this.mockMvc.perform(
                post("/recipe-drafts/{id}/publish", recipeDraft.getId())
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id", is(notNullValue())))
                .andExpect(jsonPath("$.created", is(notNullValue())))
                .andExpect(jsonPath("$.modified", is(nullValue())))
                .andExpect(jsonPath("$.title", is("Test Title")))
                .andExpect(jsonPath("$.slug", is("test-slug")))
                .andExpect(jsonPath("$.preparationTime", is(nullValue())))
                .andExpect(jsonPath("$.cookingTime", is(nullValue())))
                .andExpect(jsonPath("$.totalTime", is(nullValue())))
                .andExpect(jsonPath("$.text", is("<h1>Hello, World!</h1>")))
                .andExpect(jsonPath("$.owner.id", is(owner.getId())))
                .andExpect(jsonPath("$.owner.username", is(owner.getUsername())))
                .andExpect(jsonPath("$.starCount", is(0)))
                .andExpect(jsonPath("$.viewerCount", is(0)))
                .andExpect(jsonPath("$.mainImage", is(nullValue())))
                .andExpect(jsonPath("$.public", is(false)));
        assertThrows(NoSuchEntityWithIdException.class, () -> this.recipeService.getDraftById(recipeDraft.getId()));
    }

    @Test
    public void whenPublishWithNoPrincipal_returnsUnauthorized() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        this.mockMvc.perform(
                post("/recipe-drafts/{id}/publish", recipeDraft.getId())
        ).andExpect(status().isUnauthorized());
    }

    @Test
    public void whenPublishWrongPrincipal_returnsForbidden() throws Exception {
        final User owner = this.seedUser();
        final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
        final User wrongPublisher = this.seedUser();
        this.mockMvc.perform(
                post("/recipe-drafts/{id}/publish", recipeDraft.getId())
                        .header("Authorization", "Bearer " + this.getAccessToken(wrongPublisher))
        ).andExpect(status().isForbidden());
    }

}
