package org.raddatz.familienarchiv.document;

import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.io.RandomAccessReadBuffer;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.ThumbnailAspect;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;

/**
 * Generates JPEG thumbnail previews for documents (PDF first-page or scaled-down image)
 * and uploads them to the S3 thumbnails/ prefix. Fire-and-forget from upload paths via
 * {@link ThumbnailAsyncRunner}; also invoked by {@link ThumbnailBackfillService} for
 * historical documents. Explicitly does not throw — failures are returned as
 * {@link Outcome#FAILED} so the backfill can account for them without aborting the run.
 */
@Service
@Slf4j
public class ThumbnailService {

    public enum Outcome { SUCCESS, SKIPPED, FAILED }

    private static final int THUMBNAIL_WIDTH = 240;
    private static final float JPEG_QUALITY = 0.85f;
    private static final int PDF_RENDER_DPI = 100;
    // Anything below this w/h ratio stays PORTRAIT — near-square A4 scans should
    // render in the portrait tile rather than flipping to landscape at 1.01.
    private static final float LANDSCAPE_THRESHOLD = 1.1f;
    private static final String PDF_CONTENT_TYPE = "application/pdf";
    private static final Set<String> IMAGE_CONTENT_TYPES =
            Set.of("image/jpeg", "image/png", "image/tiff");

    // Deterministic S3 key — `thumbnails/{docId}.jpg`. When a document's file is replaced
    // the regenerated thumbnail overwrites this same key via PutObject, so we never
    // orphan old thumbnails. The URL-level cache buster is the `thumbnail_generated_at`
    // timestamp (see /api/documents/{id}/thumbnail ?v= param).
    private static final String THUMBNAIL_KEY_PREFIX = "thumbnails/";
    private static final String THUMBNAIL_KEY_SUFFIX = ".jpg";

    private final FileService fileService;
    private final S3Client s3Client;
    private final DocumentRepository documentRepository;

    @Value("${app.s3.bucket}")
    private String bucketName;

    public ThumbnailService(FileService fileService, S3Client s3Client,
                            DocumentRepository documentRepository) {
        this.fileService = fileService;
        this.s3Client = s3Client;
        this.documentRepository = documentRepository;
    }

    public Outcome generate(Document doc) {
        if (doc.getFilePath() == null) {
            log.debug("Document {} has no filePath, skipping thumbnail", doc.getId());
            return Outcome.SKIPPED;
        }
        String contentType = doc.getContentType();
        if (contentType == null || !isSupported(contentType)) {
            log.warn("Document {} has unsupported contentType {}, skipping thumbnail",
                    doc.getId(), contentType);
            return Outcome.SKIPPED;
        }

        SourcePreview preview = readSourcePreview(doc, contentType);
        if (preview == null
                || preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) {
            log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId());
            return Outcome.FAILED;
        }

        byte[] jpeg = encodeThumbnail(preview.image(), doc.getId());
        if (jpeg == null) return Outcome.FAILED;

        String thumbnailKey = thumbnailKeyFor(doc.getId());
        if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;

        ThumbnailResult result = new ThumbnailResult(
                thumbnailKey, aspectOf(preview.image()), preview.pageCount());
        return persistThumbnailMetadata(doc, result);
    }

    private static ThumbnailAspect aspectOf(BufferedImage source) {
        float ratio = (float) source.getWidth() / source.getHeight();
        return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT;
    }

    // First-page image + total page count for the source file. Page count is always
    // 1 for image uploads; for PDFs it comes straight from PDDocument.
    private record SourcePreview(BufferedImage image, int pageCount) {}

    // Everything the generate pipeline has already committed to storage and
    // now wants stamped onto the Document entity in a single save call.
    private record ThumbnailResult(String key, ThumbnailAspect aspect, int pageCount) {}

    private static String thumbnailKeyFor(UUID documentId) {
        return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
    }

    private SourcePreview readSourcePreview(Document doc, String contentType) {
        try {
            return PDF_CONTENT_TYPE.equals(contentType)
                    ? renderPdfFirstPage(doc.getFilePath())
                    : new SourcePreview(readImage(doc.getFilePath()), 1);
        } catch (Exception e) {
            log.warn("Thumbnail source read failed for doc={} reason={}",
                    doc.getId(), e.getMessage());
            return null;
        }
    }

    private byte[] encodeThumbnail(BufferedImage source, UUID documentId) {
        try {
            BufferedImage scaled = scaleToWidth(source, THUMBNAIL_WIDTH);
            return encodeJpeg(scaled, JPEG_QUALITY);
        } catch (Exception e) {
            log.warn("Thumbnail JPEG encoding failed for doc={} reason={}",
                    documentId, e.getMessage());
            return null;
        }
    }

    private boolean uploadToStorage(String thumbnailKey, byte[] jpeg, UUID documentId) {
        try {
            s3Client.putObject(
                    PutObjectRequest.builder()
                            .bucket(bucketName)
                            .key(thumbnailKey)
                            .contentType("image/jpeg")
                            .build(),
                    RequestBody.fromBytes(jpeg));
            return true;
        } catch (Exception e) {
            log.warn("Thumbnail upload failed for doc={} key={} reason={}",
                    documentId, thumbnailKey, e.getMessage());
            return false;
        }
    }

    private Outcome persistThumbnailMetadata(Document doc, ThumbnailResult result) {
        try {
            doc.setThumbnailKey(result.key());
            doc.setThumbnailGeneratedAt(LocalDateTime.now());
            doc.setThumbnailAspect(result.aspect());
            doc.setPageCount(result.pageCount());
            documentRepository.save(doc);
            return Outcome.SUCCESS;
        } catch (Exception e) {
            // Thumbnail is already in S3 but the entity update failed. Because the S3
            // key is deterministic (thumbnails/{docId}.jpg), the next successful run
            // — either a re-upload of this document or the admin backfill — will
            // overwrite it cleanly. Logging distinctly so an operator tracking
            // backfill totals can spot the database-side issue.
            log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
                    doc.getId(), result.key(), e.getMessage());
            return Outcome.FAILED;
        }
    }

    private boolean isSupported(String contentType) {
        return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
    }

    private SourcePreview renderPdfFirstPage(String s3Key) throws IOException {
        try (InputStream in = fileService.downloadFileStream(s3Key);
             PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
            PDFRenderer renderer = new PDFRenderer(pdf);
            BufferedImage image = renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB);
            return new SourcePreview(image, pdf.getNumberOfPages());
        }
    }

    private BufferedImage readImage(String s3Key) throws IOException {
        try (InputStream in = fileService.downloadFileStream(s3Key)) {
            BufferedImage img = ImageIO.read(in);
            if (img == null) {
                throw new IOException("No ImageIO reader available for " + s3Key);
            }
            return img;
        }
    }

    private BufferedImage scaleToWidth(BufferedImage source, int targetWidth) {
        int sourceWidth = source.getWidth();
        int sourceHeight = source.getHeight();
        int targetHeight = Math.max(1, Math.round((float) targetWidth * sourceHeight / sourceWidth));
        BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = scaled.createGraphics();
        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
        g.dispose();
        return scaled;
    }

    private byte[] encodeJpeg(BufferedImage image, float quality) throws IOException {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
        ImageWriteParam param = writer.getDefaultWriteParam();
        param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
        param.setCompressionQuality(quality);
        try (ImageOutputStream out = ImageIO.createImageOutputStream(bos)) {
            writer.setOutput(out);
            writer.write(null, new IIOImage(image, null, null), param);
        } finally {
            writer.dispose();
        }
        return bos.toByteArray();
    }
}
