package app.mealsmadeeasy.api.image;

import app.mealsmadeeasy.api.PostgresTestsExtension;
import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginException;
import app.mealsmadeeasy.api.image.body.ImageUpdateBody;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
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.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.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;

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

import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.empty;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(PostgresTestsExtension.class)
public class ImageControllerTests {

    private static InputStream getHal9000InputStream() {
        return ImageControllerTests.class.getResourceAsStream("HAL9000.svg");
    }

    @Autowired
    private UserService userService;

    @Autowired
    private AuthService authService;

    @Autowired
    private ImageService imageService;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    private static final String TEST_PASSWORD = "test";
    private static final long HAL_SIZE = 27881L;

    private static String makeUserFilename() {
        return UUID.randomUUID() + ".svg";
    }

    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 Image seedImage(User owner, ImageCreateSpec spec) {
        try {
            return this.imageService.create(
                    owner,
                    makeUserFilename(),
                    getHal9000InputStream(),
                    HAL_SIZE,
                    spec
            );
        } catch (IOException | ImageException e) {
            throw new RuntimeException(e);
        }
    }

    private Image seedImage(User owner) {
        return this.seedImage(owner, ImageCreateSpec.builder().build());
    }

    private static String getImageUrl(User owner, Image image) {
        return "/images/" + owner.getUsername() + "/" + image.getUserFilename();
    }

    private String getAccessToken(User user) {
        try {
            return this.authService.login(user.getUsername(), TEST_PASSWORD).getAccessToken().getToken();
        } catch (LoginException e) {
            throw new RuntimeException(e);
        }
    }

    @Test
    public void getPublicImageNoPrincipal() throws Exception {
        final User owner = this.seedUser();
        final ImageCreateSpec spec = ImageCreateSpec.builder()
                .isPublic(true)
                .build();
        final Image image = this.seedImage(owner, spec);

        // Assert bytes the same and proper mime type
        try (final InputStream hal9000 = getHal9000InputStream()) {
            final byte[] halBytes = hal9000.readAllBytes();
            this.mockMvc.perform(get(getImageUrl(owner, image)))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType("image/svg+xml"))
                    .andExpect(content().bytes(halBytes));
        }
    }

    private void doGetImageTestWithAccessToken(User owner, Image image, String accessToken) throws Exception {
        try (final InputStream hal9000 = getHal9000InputStream()) {
            final byte[] halBytes = hal9000.readAllBytes();
            this.mockMvc.perform(get(getImageUrl(owner, image))
                            .header("Authorization", "Bearer " + accessToken))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType("image/svg+xml"))
                    .andExpect(content().bytes(halBytes));
        }
    }

    @Test
    public void getOwnedImages() throws Exception {
        final User owner = this.seedUser();
        this.seedImage(owner);
        this.mockMvc.perform(
                get("/images")
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.content").isArray())
                .andExpect(jsonPath("$.content").isNotEmpty())
                .andExpect(jsonPath("$.content[0].url").isNotEmpty())
                .andExpect(jsonPath("$.slice.size").isNotEmpty())
                .andExpect(jsonPath("$.slice.number", is(0)))
                .andExpect(jsonPath("$.slice.hasNext", is(false)));
    }

    @Test
    public void getImageForOwner() throws Exception {
        final User owner = this.seedUser();
        final Image image = this.seedImage(owner);
        this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(owner));
    }

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

        // add viewer
        final ImageUpdateSpec spec = ImageUpdateSpec.builder()
                .viewersToAdd(Set.of(viewer))
                .build();
        this.imageService.update(image, owner, spec);

        this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(viewer));
    }

    @Test
    public void getNonPublicImageNoPrincipalForbidden() throws Exception {
        final User owner = this.seedUser();
        final Image image = this.seedImage(owner);
        this.mockMvc.perform(get(getImageUrl(owner, image)))
                .andExpect(status().isForbidden());
    }

    @Test
    public void getNonPublicImageWithPrincipalForbidden() throws Exception {
        final User owner = this.seedUser();
        final Image image = this.seedImage(owner);
        final User nonViewer = this.seedUser();
        final String nonViewerToken = this.getAccessToken(nonViewer);
        this.mockMvc.perform(
                get(getImageUrl(owner, image))
                        .header("Authorization", "Bearer " + nonViewerToken)
        ).andExpect(status().isForbidden());
    }

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

        // add viewer
        final ImageUpdateSpec spec = ImageUpdateSpec.builder()
                .viewersToAdd(Set.of(viewer))
                .build();
        this.imageService.update(image, owner, spec);

        this.mockMvc.perform(get(getImageUrl(owner, image))).andExpect(status().isForbidden());
    }

    @Test
    public void uploadImage() throws Exception {
        final User owner = this.seedUser();
        final String accessToken = this.getAccessToken(owner);
        try (final InputStream hal9000 = getHal9000InputStream()) {
            final String userFilename = makeUserFilename();
            final MockMultipartFile mockMultipartFile = new MockMultipartFile(
                    "image", userFilename, "image/svg+xml", hal9000
            );
            this.mockMvc.perform(
                    multipart("/images")
                            .file(mockMultipartFile)
                            .param("filename", userFilename)
                            .param("alt", "HAL 9000")
                            .param("caption", "HAL 9000, from 2001: A Space Odyssey")
                            .param("isPublic", "true")
                            .header("Authorization", "Bearer " + accessToken)
            )
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.created").exists())
                    .andExpect(jsonPath("$.modified").value(nullValue()))
                    .andExpect(jsonPath("$.filename").value(userFilename))
                    .andExpect(jsonPath("$.mimeType").value("image/svg+xml"))
                    .andExpect(jsonPath("$.alt").value("HAL 9000"))
                    .andExpect(jsonPath("$.caption").value("HAL 9000, from 2001: A Space Odyssey"))
                    .andExpect(jsonPath("$.public").value(true))
                    .andExpect(jsonPath("$.owner.username").value(owner.getUsername()))
                    .andExpect(jsonPath("$.owner.id").value(owner.getId()))
                    .andExpect(jsonPath("$.viewers").value(empty()));
        }
    }

    @Test
    public void updateAlt() throws Exception {
        final User owner = this.seedUser();
        final Image image = this.seedImage(owner);
        final String accessToken = this.getAccessToken(owner);
        final ImageUpdateBody body = new ImageUpdateBody();
        body.setAlt("HAL 9000");
        this.mockMvc.perform(
                put(getImageUrl(owner, image))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(body))
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.modified").value(notNullValue()))
                .andExpect(jsonPath("$.alt").value("HAL 9000"));
    }

    @Test
    public void updateCaption() throws Exception {
        final User owner = this.seedUser();
        final Image image = this.seedImage(owner);
        final String accessToken = this.getAccessToken(owner);
        final ImageUpdateBody body = new ImageUpdateBody();
        body.setCaption("HAL 9000 from 2001: A Space Odyssey");
        this.mockMvc.perform(
                put(getImageUrl(owner, image))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(body))
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.modified").value(notNullValue()))
                .andExpect(jsonPath("$.caption").value("HAL 9000 from 2001: A Space Odyssey"));
    }

    @Test
    public void updateIsPublic() throws Exception {
        final User owner = this.seedUser();
        final Image image = this.seedImage(owner);
        final String accessToken = this.getAccessToken(owner);
        final ImageUpdateBody body = new ImageUpdateBody();
        body.setIsPublic(true);
        this.mockMvc.perform(
                put(getImageUrl(owner, image))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(body))
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.modified").value(notNullValue()))
                .andExpect(jsonPath("$.public").value(true));
    }

    @Test
    public void addViewers() throws Exception {
        final User owner = this.seedUser();
        final User viewerToAdd = this.seedUser();
        final Image image = this.seedImage(owner);
        final String accessToken = this.getAccessToken(owner);

        final ImageUpdateBody body = new ImageUpdateBody();
        final Set<String> viewerUsernames = Set.of(viewerToAdd.getUsername());
        body.setViewersToAdd(viewerUsernames);

        this.mockMvc.perform(
                put(getImageUrl(owner, image))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(body))
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.modified").value(notNullValue()))
                .andExpect(jsonPath("$.viewers").value(not(empty())))
                .andExpect(jsonPath("$.viewers[0].username").value(viewerToAdd.getUsername()));
    }

    private record OwnerViewerImage(User owner, User viewer, Image image) {}

    private OwnerViewerImage prepOwnerViewerImage() {
        final User owner = this.seedUser();
        final User viewer = this.seedUser();
        final Image image = this.seedImage(owner);
        final ImageUpdateSpec spec = ImageUpdateSpec.builder()
                .viewersToAdd(Set.of(viewer))
                .build();
        this.imageService.update(image, owner, spec);
        return new OwnerViewerImage(owner, viewer, image);
    }

    @Test
    public void removeViewers() throws Exception {
        final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
        final String accessToken = this.getAccessToken(ownerViewerImage.owner());

        final ImageUpdateBody body = new ImageUpdateBody();
        body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername()));

        this.mockMvc.perform(
                put(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(body))
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.modified").value(notNullValue()))
                .andExpect(jsonPath("$.viewers").value(empty()));
    }

    @Test
    public void clearAllViewers() throws Exception {
        final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
        final String accessToken = this.getAccessToken(ownerViewerImage.owner());
        final ImageUpdateBody body = new ImageUpdateBody();
        body.setClearAllViewers(true);
        this.mockMvc.perform(
                put(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(body))
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.modified").value(notNullValue()))
                .andExpect(jsonPath("$.viewers").value(empty()));
    }

    @Test
    public void updateInfoByViewerForbidden() throws Exception {
        final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
        final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer
        final ImageUpdateBody body = new ImageUpdateBody();
        this.mockMvc.perform(
                put(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(body))
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.statusCode").value(403))
                .andExpect(jsonPath("$.message").value(notNullValue()));
    }

    @Test
    public void deleteImageWithOwner() throws Exception {
        final User owner = this.seedUser();
        final Image image = this.seedImage(owner);
        final String accessToken = this.getAccessToken(owner);
        this.mockMvc.perform(
                delete(getImageUrl(owner, image))
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isNoContent());
        assertThrows(NoSuchEntityWithIdException.class, () -> this.imageService.getById(image.getId(), owner));
    }

    @Test
    public void deleteImageByViewerForbidden() throws Exception {
        final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
        final String accessToken = this.getAccessToken(ownerViewerImage.viewer());
        this.mockMvc.perform(
                delete(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
                        .header("Authorization", "Bearer " + accessToken)
        )
                .andExpect(status().isForbidden());
    }

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

        this.mockMvc.perform(
                get("/images/{username}/{filename}/exists", image.getOwner().getUsername(), image.getUserFilename())
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.exists").value(true));
    }

    @Test
    public void whenImageDoesNotExist_existsIsFalse() throws Exception {
        final User owner = this.seedUser();
        this.mockMvc.perform(
                get("/images/{username}/fake-filename.jpeg/exists", owner.getUsername())
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.exists").value(false));
    }

    @Test
    public void cannotDoExistsOnInaccessibleImage() throws Exception {
        final User owner = this.seedUser();
        final User viewer = this.seedUser();
        final Image image = this.seedImage(owner);
        final String viewerAccessToken = this.getAccessToken(viewer);

        this.mockMvc.perform(
                get("/images/{username}/{filename}/exists", owner.getUsername(), image.getUserFilename())
                        .header("Authorization", "Bearer " + viewerAccessToken)
        )
                .andExpect(status().isForbidden());
    }

    @Test
    public void getCountOfOwnedImages() throws Exception {
        final User owner = this.seedUser();
        this.mockMvc.perform(
                get("/images?count=true")
                        .header("Authorization", "Bearer " + this.getAccessToken(owner))
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.count").isNotEmpty());
    }

}
