package app.mealsmadeeasy.api.image;

import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.s3.S3Manager;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.util.MimeTypeService;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import app.mealsmadeeasy.api.util.NoSuchEntityWithUsernameAndFilenameException;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Directory;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.jpeg.JpegDirectory;
import com.drew.metadata.png.PngDirectory;
import com.drew.metadata.webp.WebpDirectory;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Value;
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 javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.OffsetDateTime;
import java.util.*;

@Service
public class S3ImageService implements ImageService {

    private final S3Manager s3Manager;
    private final ImageRepository imageRepository;
    private final String imageBucketName;
    private final MimeTypeService mimeTypeService;

    public S3ImageService(
            S3Manager s3Manager,
            ImageRepository imageRepository,
            @Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName,
            MimeTypeService mimeTypeService
    ) {
        this.s3Manager = s3Manager;
        this.imageRepository = imageRepository;
        this.imageBucketName = imageBucketName;
        this.mimeTypeService = mimeTypeService;
    }

    private boolean transferFromCreateSpec(Image entity, ImageCreateSpec spec) {
        boolean didTransfer = false;
        if (spec.getAlt() != null) {
            entity.setAlt(spec.getAlt());
            didTransfer = true;
        }
        if (spec.getCaption() != null) {
            entity.setCaption(spec.getCaption());
            didTransfer = true;
        }
        if (spec.getIsPublic() != null) {
            entity.setIsPublic(spec.getIsPublic());
            didTransfer = true;
        }
        final @Nullable Set<User> viewersToAdd = spec.getViewersToAdd();
        if (viewersToAdd != null) {
            final Set<User> viewers = new HashSet<>(entity.getViewers());
            viewers.addAll(spec.getViewersToAdd());
            entity.setViewers(viewers);
            didTransfer = true;
        }
        return didTransfer;
    }

    private boolean transferFromUpdateSpec(Image entity, ImageUpdateSpec spec) {
        boolean didTransfer = false;
        if (spec.getAlt() != null) {
            entity.setAlt(spec.getAlt());
            didTransfer = true;
        }
        if (spec.getCaption() != null) {
            entity.setCaption(spec.getCaption());
            didTransfer = true;
        }
        if (spec.getIsPublic() != null) {
            entity.setIsPublic(spec.getIsPublic());
            didTransfer = true;
        }
        final @Nullable Set<User> viewersToAdd = spec.getViewersToAdd();
        if (viewersToAdd != null) {
            final Set<User> viewers = new HashSet<>(entity.getViewers());
            viewers.addAll(spec.getViewersToAdd());
            entity.setViewers(viewers);
            didTransfer = true;
        }
        final @Nullable Set<User> viewersToRemove = spec.getViewersToRemove();
        if (viewersToRemove != null) {
            final Set<User> viewers = new HashSet<>(entity.getViewers());
            viewers.removeAll(spec.getViewersToRemove());
            entity.setViewers(viewers);
            didTransfer = true;
        }
        if (spec.getClearAllViewers() != null && spec.getClearAllViewers()) {
            entity.setViewers(new HashSet<>());
            didTransfer = true;
        }
        return didTransfer;
    }

    private record HeightWidth(int height, int width) {}

    private <D extends Directory> HeightWidth getHeightAndWidthFromMetadata(
            InputStream inputStream,
            Class<D> directoryClass,
            int heightTag,
            int widthTag,
            String debugName
    ) throws IOException {
        try {
            final Metadata metadata = ImageMetadataReader.readMetadata(inputStream);
            final D directory = metadata.getFirstDirectoryOfType(directoryClass);
            if (directory == null) {
                throw new RuntimeException("Unable to get " + directoryClass.getSimpleName() + " for " + debugName);
            }
            if (!directory.containsTag(heightTag)) {
                throw new RuntimeException("Unable to find height tag for " + debugName);
            }
            if (!directory.containsTag(widthTag)) {
                throw new RuntimeException("Unable to find width tag for " + debugName);
            }
            return new HeightWidth(directory.getInt(heightTag), directory.getInt(widthTag));
        } catch (ImageProcessingException | MetadataException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * @apiNote Consumes and closes the {@link java.io.InputStream}.
     *
     * @param owner the User owner
     * @param userFilename the name of the uploaded file from the user
     * @param inputStream the image content
     * @param objectSize the size of the image, in bytes
     * @param createSpec the metadata for the image
     * @return an {@link app.mealsmadeeasy.api.image.Image} representing the stored image
     * @throws IOException if there are any errors related to IO while storing the image
     * @throws ImageException if there are any errors processing the image
     */
    @Override
    public Image create(
            User owner,
            String userFilename,
            InputStream inputStream,
            long objectSize,
            ImageCreateSpec createSpec
    ) throws IOException, ImageException {
        final String mimeType = this.mimeTypeService.getMimeType(userFilename);
        final String uuid = UUID.randomUUID().toString();
        final String extension = this.mimeTypeService.getExtension(mimeType);
        final String filename = uuid + "." + extension;

        final var baos = new ByteArrayOutputStream();
        inputStream.transferTo(baos);
        final InputStream toStore = new ByteArrayInputStream(baos.toByteArray());
        final InputStream toRead =  new ByteArrayInputStream(baos.toByteArray());

        final String objectName = this.s3Manager.store(
                this.imageBucketName, filename, mimeType, toStore, objectSize
        );

        final HeightWidth hw;
        switch (mimeType) {
            case "image/jpeg" -> {
                hw = this.getHeightAndWidthFromMetadata(
                        toRead,
                        JpegDirectory.class,
                        JpegDirectory.TAG_IMAGE_HEIGHT,
                        JpegDirectory.TAG_IMAGE_WIDTH,
                        userFilename
                );
            }
            case "image/png" -> {
                hw = this.getHeightAndWidthFromMetadata(
                        toRead,
                        PngDirectory.class,
                        PngDirectory.TAG_IMAGE_HEIGHT,
                        PngDirectory.TAG_IMAGE_WIDTH,
                        userFilename
                );
            }
            case "image/webp" -> {
                hw = this.getHeightAndWidthFromMetadata(
                        toRead,
                        WebpDirectory.class,
                        WebpDirectory.TAG_IMAGE_HEIGHT,
                        WebpDirectory.TAG_IMAGE_WIDTH,
                        userFilename
                );
            }
            case "image/svg+xml" -> {
                final BufferedImage bufferedImage = ImageIO.read(toRead);
                hw = new HeightWidth(bufferedImage.getHeight(), bufferedImage.getWidth());
            }
            default -> throw new RuntimeException("Unsupported mime type: " + mimeType);
        }
        final int height = hw.height();
        final int width = hw.width();

        toRead.close();
        toStore.close();
        inputStream.close();

        final Image draft = new Image();
        draft.setOwner(owner);
        draft.setUserFilename(userFilename);
        draft.setMimeType(mimeType);
        draft.setObjectName(objectName);
        draft.setHeight(height);
        draft.setWidth(width);
        this.transferFromCreateSpec(draft, createSpec);
        return this.imageRepository.save(draft);
    }

    @Override
    @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
    public Image getById(Integer id, @Nullable User viewer) {
        return this.imageRepository.findById(id).orElseThrow(() -> new NoSuchEntityWithIdException(Image.class, id));
    }

    @Override
    @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
    public Image getByOwnerAndFilename(User owner, String filename, User viewer) {
        return this.imageRepository.findByOwnerAndUserFilename(owner, filename)
                .orElseThrow(() -> new NoSuchEntityWithUsernameAndFilenameException(
                        Image.class,
                        owner.getUsername(),
                        filename
                ));
    }

    @Override
    @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
    public Image getByUsernameAndFilename(String username, String filename, User viewer) {
        return this.imageRepository.findByOwnerUsernameAndFilename(username, filename).orElseThrow(
                () -> new NoSuchEntityWithUsernameAndFilenameException(Image.class, username, filename)
        );
    }

    @Override
    @PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)")
    public InputStream getImageContent(Image image, User viewer) throws IOException {
        return this.s3Manager.load(this.imageBucketName, image.getObjectName());
    }

    @Override
    public List<Image> getImagesOwnedBy(User user) {
        return new ArrayList<>(this.imageRepository.findAllByOwner(user));
    }

    @Override
    public Slice<Image> getOwnedImages(User user, Pageable pageable) {
        return this.imageRepository.findAllOwnedBy(user, pageable);
    }

    @Override
    public int countOwnedImages(User user) {
        return this.imageRepository.countAllByOwner(user);
    }

    @Override
    @PreAuthorize("@imageSecurity.canSeeExists(#viewer, #username, #filename)")
    public boolean exists(User viewer, String username, String filename) {
        return this.imageRepository.findByOwnerUsernameAndFilename(username, filename).isPresent();
    }

    @Override
    @PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
    public Image update(final Image image, User modifier, ImageUpdateSpec updateSpec) {
        final boolean didUpdate = this.transferFromUpdateSpec(image, updateSpec);
        if (didUpdate) {
            image.setModified(OffsetDateTime.now());
        }
        return this.imageRepository.save(image);
    }

    @Override
    @PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
    public void deleteImage(Image image, User modifier) throws IOException {
        this.imageRepository.delete(image);
        this.s3Manager.delete("images", image.getObjectName());
    }

}
