package org.raddatz.familienarchiv.document;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.document.ThumbnailAspect;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.springframework.test.util.ReflectionTestUtils;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;

import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;

class ThumbnailServiceTest {

    private FileService fileService;
    private S3Client s3Client;
    private DocumentRepository documentRepository;
    private ThumbnailService thumbnailService;

    @BeforeEach
    void setUp() {
        fileService = mock(FileService.class);
        s3Client = mock(S3Client.class);
        documentRepository = mock(DocumentRepository.class);
        thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository);
        ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket");
        when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0));
    }

    @Test
    void generate_returnsSkipped_whenDocumentHasNoFilePath() {
        Document doc = makeDoc("application/pdf", null);

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
        verifyNoInteractions(s3Client);
        assertThat(doc.getThumbnailKey()).isNull();
    }

    @Test
    void generate_returnsSkipped_forUnsupportedContentType() throws IOException {
        Document doc = makeDoc("application/msword", "documents/letter.doc");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(new byte[]{1, 2, 3}));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
        verifyNoInteractions(s3Client);
        assertThat(doc.getThumbnailKey()).isNull();
    }

    @Test
    void generate_rendersPdf_uploadsJpeg_updatesEntity() throws IOException {
        Document doc = makeDoc("application/pdf", "documents/letter.pdf");
        byte[] pdfBytes = createSamplePdf();
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(pdfBytes));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);

        ArgumentCaptor<PutObjectRequest> putCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
        ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
        verify(s3Client).putObject(putCaptor.capture(), bodyCaptor.capture());

        PutObjectRequest req = putCaptor.getValue();
        assertThat(req.bucket()).isEqualTo("test-bucket");
        assertThat(req.key()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
        assertThat(req.contentType()).isEqualTo("image/jpeg");

        byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
        BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
        assertThat(jpg).isNotNull();
        assertThat(jpg.getWidth()).isEqualTo(240);

        assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
        assertThat(doc.getThumbnailGeneratedAt()).isNotNull();
        verify(documentRepository).save(doc);
    }

    @Test
    void generate_rendersPng_uploadsJpegAtWidth240() throws IOException {
        Document doc = makeDoc("image/png", "documents/scan.png");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
        ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
        verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
        byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
        BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
        assertThat(jpg.getWidth()).isEqualTo(240);
        assertThat(jpg.getHeight()).isEqualTo(320); // 600x800 -> 240x320
    }

    @Test
    void generate_rendersJpeg_uploadsScaledJpeg() throws IOException {
        Document doc = makeDoc("image/jpeg", "documents/photo.jpg");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
        ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
        verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
        BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(
                readAll(bodyCaptor.getValue().contentStreamProvider().newStream())));
        assertThat(jpg.getWidth()).isEqualTo(240);
        assertThat(jpg.getHeight()).isEqualTo(120); // 800x400 -> 240x120
    }

    @Test
    void generate_returnsFailed_whenS3PutThrows() throws IOException {
        Document doc = makeDoc("application/pdf", "documents/letter.pdf");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSamplePdf()));
        when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
                .thenThrow((S3Exception) S3Exception.builder().message("quota exceeded").statusCode(507).build());

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
        assertThat(doc.getThumbnailKey()).isNull();
        verify(documentRepository, never()).save(any());
    }

    @Test
    void generate_returnsFailed_whenSourceStreamThrows() throws IOException {
        Document doc = makeDoc("application/pdf", "documents/letter.pdf");
        when(fileService.downloadFileStream(anyString()))
                .thenThrow(new IOException("network blip"));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
        verifyNoInteractions(s3Client);
        verify(documentRepository, never()).save(any());
    }

    @Test
    void generate_persistsPageCount_ofOne_forSingleImageUpload() throws IOException {
        // Image uploads are always a single page from the user's perspective.
        Document doc = makeDoc("image/png", "documents/scan.png");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
        assertThat(doc.getPageCount()).isEqualTo(1);
    }

    @Test
    void generate_persistsPageCount_fromPdfDocument() throws IOException {
        Document doc = makeDoc("application/pdf", "documents/multi.pdf");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSamplePdf(3)));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
        assertThat(doc.getPageCount()).isEqualTo(3);
    }

    @Test
    void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException {
        // 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT.
        Document doc = makeDoc("image/png", "documents/portrait.png");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
        assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
    }

    @Test
    void generate_persistsLandscapeAspect_whenWidthIsWellAboveHeight() throws IOException {
        // 800x400 → ratio 2.0 → clearly above 1.1 → LANDSCAPE.
        Document doc = makeDoc("image/jpeg", "documents/wide.jpg");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
        assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
    }

    @Test
    void generate_persistsPortraitAspect_whenSquareImage_belowLandscapeThreshold() throws IOException {
        // 500x500 → ratio 1.0 → below 1.1 threshold → PORTRAIT (A4 scans often
        // come in at near-square and we want them to live in the portrait tile).
        Document doc = makeDoc("image/png", "documents/square.png");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSamplePng(500, 500)));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
        assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
    }

    @Test
    void generate_persistsPortraitAspect_justUnderLandscapeThreshold() throws IOException {
        // 1099x1000 → ratio 1.099 → just under 1.1 threshold → PORTRAIT.
        Document doc = makeDoc("image/png", "documents/near_threshold.png");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSamplePng(1099, 1000)));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
        assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
    }

    @Test
    void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException {
        // Truncated JPEG header — ImageIO returns null rather than throwing.
        // Without the corrupt-image guard this would later NPE inside the aspect /
        // dimension computation in scaleToWidth.
        Document doc = makeDoc("image/jpeg", "documents/corrupt.jpg");
        byte[] truncated = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0};
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(truncated));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
        verifyNoInteractions(s3Client);
        verify(documentRepository, never()).save(any());
    }

    @Test
    void generate_returnsFailed_whenPdfBytesAreCorrupt() throws IOException {
        // "PDF" header but no body — PDFBox throws IOException while loading.
        Document doc = makeDoc("application/pdf", "documents/corrupt.pdf");
        byte[] fakePdf = "%PDF-1.4\n".getBytes();
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(fakePdf));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
        verifyNoInteractions(s3Client);
        verify(documentRepository, never()).save(any());
    }

    @Test
    void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException {
        // Covers the "orphan thumbnail" edge case: S3 upload succeeded but the
        // entity update blew up. We must still return FAILED so the backfill
        // tally is honest, without losing the fact that we already put bytes in S3.
        Document doc = makeDoc("application/pdf", "documents/letter.pdf");
        when(fileService.downloadFileStream(anyString()))
                .thenReturn(new ByteArrayInputStream(createSamplePdf()));
        when(documentRepository.save(any()))
                .thenThrow(new RuntimeException("constraint violation"));

        ThumbnailService.Outcome outcome = thumbnailService.generate(doc);

        assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
        verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
        verify(documentRepository).save(any());
    }

    // ─── helpers ──────────────────────────────────────────────────────────────

    private Document makeDoc(String contentType, String filePath) {
        Document doc = Document.builder()
                .id(UUID.randomUUID())
                .title("Test Doc")
                .originalFilename("test.pdf")
                .status(DocumentStatus.UPLOADED)
                .contentType(contentType)
                .filePath(filePath)
                .build();
        doc.setCreatedAt(LocalDateTime.now());
        doc.setUpdatedAt(LocalDateTime.now());
        return doc;
    }

    private static byte[] createSamplePdf() throws IOException {
        return createSamplePdf(1);
    }

    private static byte[] createSamplePdf(int pageCount) throws IOException {
        try (PDDocument doc = new PDDocument()) {
            for (int i = 0; i < pageCount; i++) {
                PDPage page = new PDPage(PDRectangle.A4);
                doc.addPage(page);
                try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
                    content.beginText();
                    content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
                    content.newLineAtOffset(100, 700);
                    content.showText("Lieber Hans,");
                    content.endText();
                }
            }
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            doc.save(bos);
            return bos.toByteArray();
        }
    }

    private static byte[] createSamplePng(int width, int height) throws IOException {
        BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = img.createGraphics();
        g.setColor(Color.LIGHT_GRAY);
        g.fillRect(0, 0, width, height);
        g.setColor(Color.DARK_GRAY);
        g.fillRect(0, 0, width, height / 4);
        g.dispose();
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ImageIO.write(img, "png", bos);
        return bos.toByteArray();
    }

    private static byte[] createSampleJpeg(int width, int height) throws IOException {
        BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = img.createGraphics();
        g.setColor(Color.WHITE);
        g.fillRect(0, 0, width, height);
        g.dispose();
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ImageIO.write(img, "jpg", bos);
        return bos.toByteArray();
    }

    private static byte[] readAll(InputStream stream) throws IOException {
        try (stream) {
            return stream.readAllBytes();
        }
    }
}
