/*
 * Copyright (c) 1997-2024 IDRsolutions (https://www.idrsolutions.com)
 */
package org.jpedal.render.output.io;

import com.idrsolutions.image.jpeg.JpegEncoder;
import com.idrsolutions.image.png.PngEncoder;
import com.idrsolutions.image.png.options.PngCompressionFormat;
import org.jpedal.utils.LogWriter;

import java.awt.image.BufferedImage;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPOutputStream;

public class DefaultIO implements CustomIO {

    // Put image output inside a thread to speed up conversion
    private ExecutorService executorService;
    private Semaphore blocker;

    private final boolean useImageOutputThread;
    private final boolean useLegacyImageFileType;
    private boolean compressImages;

    /**
     * Constructs a DefaultIO that will use a separate image output thread.
     * Only safe to use if completeDocument() is called after page decodes are finished.
     */
    protected DefaultIO() {
        this(true, false);
    }

    /**
     * Constructs a DefaultIO
     *
     @param useImageOutputThread   Whether a separate image output thread should be used. (Not for ExtractPagesAsHTML)
     @param useLegacyImageFileType Whether to use legacy image file type (png)
     */
    public DefaultIO(final boolean useImageOutputThread, final boolean useLegacyImageFileType) {
        this.useImageOutputThread = useImageOutputThread;
        this.useLegacyImageFileType = useLegacyImageFileType;
    }

    @Override
    public void writeFont(final String rootDir, final String path, final byte[] rawFontData) {

        // Make sure dir exists
        final File dir = new File(rootDir + path).getParentFile();
        if (!dir.exists()) {
            dir.mkdirs();
        }

        try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(rootDir + path))) {
            bos.write(rawFontData);
        catch (final IOException e) {
            LogWriter.writeLog("Exception: " + e.getMessage());
        }
    }

    @Override
    public void writePlainTextFile(final String rootDir, final String path, final StringBuilder content) {
        writePlainTextFile(rootDir, path, content, false, false);
    }

    @Override
    public void writePlainTextAppendedFile(final String rootDir, final String path, final StringBuilder content, final boolean append) {
        writePlainTextFile(rootDir, path, content, append, false);
    }

    @Override
    public void writeGZIPCompressedFile(final String rootDir, final String path, final StringBuilder content) {
        writePlainTextFile(rootDir, path, content, false, true);
    }

    private static void writePlainTextFile(final String rootDir, final String path, final StringBuilder content, final boolean append, final boolean gzipCompress) {
        // Make sure dir exists
        final File dir = new File(rootDir + path).getParentFile();
        if (!dir.exists()) {
            dir.mkdirs();
        }

        try (BufferedOutputStream output = getOutputStream(rootDir, path, append, gzipCompress)) {
            output.write(content.toString().getBytes(StandardCharsets.UTF_8));
            output.flush();
        catch (final IOException e) {
            LogWriter.writeLog("Exception: " + e.getMessage());
        }
    }

    private static BufferedOutputStream getOutputStream(final String rootDir, final String path, final boolean append, final boolean gzipCompressthrows IOException {
        if (gzipCompress && append) {
            throw new RuntimeException("Cannot append to a gzip compressed file.");
        }
        if (gzipCompress) {
            return new BufferedOutputStream(new GZIPOutputStream(new FileOutputStream(rootDir + path)));
        else {
            return new BufferedOutputStream(new FileOutputStream(rootDir + path, append));
        }
    }

    @Override
    public void writeFileFromStream(final String rootDir, final String path, final InputStream is) {
        new File(rootDir + path).getParentFile().mkdirs();
        try (OutputStream os = new FileOutputStream(rootDir + path);
             InputStream iis = is) {

            final byte[] buffer = new byte[1_024];
            int length;
            while ((length = iis.read(buffer)) 0) {
                os.write(buffer, 0, length);
            }
            is.close();
        catch (final IOException e) {
            LogWriter.writeLog("Exception: " + e.getMessage());
        }
    }

    @Override
    public void setCompressImages(final boolean compressImages) {
        this.compressImages = compressImages;
    }

    @Override
    public void writeImage(final String rootDir, final String path, final BufferedImage image, final ImageType imageType) {

        final ImageFileType imageFileType = getImageTypeUsed(imageType);
        final String file = path + imageFileType.getFileExtension();
        final String fullPath = rootDir + file;

        // Make sure img Dir exists
        final File imgDir = new File(fullPath).getParentFile();
        if (!imgDir.exists()) {
            imgDir.mkdirs();
        }

        // Put image output inside a thread to speed up conversion (reuse the same thread for all images)
        final Runnable r = () -> {
            try {
                try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(fullPath))) {
                    writeImageToStream(image, bos, imageFileType, compressImages);
                    bos.flush();
                }
            catch (final IOException e) {
                LogWriter.writeLog("Exception: " + e.getMessage());
            finally {
                if (useImageOutputThread) {
                    blocker.release();
                }
            }
        };

        if (useImageOutputThread) {
            if (executorService == null) {
                executorService = Executors.newFixedThreadPool(1)// Single thread as ImageIO is not thread safe
                blocker = new Semaphore(2)// Limit to 2 images in the queue
            }

            try {
                blocker.acquire()// Blocks if the queue is too long
            catch (final InterruptedException e) {
                Thread.currentThread().interrupt();

                LogWriter.writeLog("Exception in handling thread " + e);
            }
            executorService.submit(r);
        else {
            r.run();
        }

    }

    protected void writeImageToStream(final BufferedImage image, final OutputStream os, final ImageFileType imageFileType, final boolean compressImagesthrows IOException {
        if (imageFileType == ImageFileType.JPG) {
            final JpegEncoder encoder = new JpegEncoder();
            encoder.getEncoderOptions().setQuality(85);
            encoder.write(image, os);
        else {
            final PngEncoder encoder = new PngEncoder();

            if (compressImages && image.getWidth() * image.getHeight() 256) {
                encoder.getEncoderOptions().setCompressionFormat(PngCompressionFormat.QUANTISED8BIT);
            }

            encoder.write(image, os);
        }
    }

    /**
     * Grab file type to use for output for specific type of image
     *
     @return ImageFileType to use
     */
    @Override
    public ImageFileType getImageTypeUsed(final ImageType imageType) {
        if (useLegacyImageFileType) {
            return ImageFileType.PNG;
        else {
            return switch (imageType) {
                case BACKGROUND, THUMBNAIL, SVG_ALPHA_NOT_REQUIRED -> ImageFileType.JPG;
                case SVG_ALPHA_REQUIRED, FORM, SHADE -> ImageFileType.PNG;
            };
        }
    }

    /**
     * If using a separate image output thread, this should be called after pages have
     * finished decoding to wait for image images to finish outputting.
     */
    @Override
    public void completeDocument() {
        waitForImageThreads();
    }

    private void waitForImageThreads() {
        if (useImageOutputThread && executorService != null) {
            executorService.shutdown();
            try {
                executorService.awaitTermination(1, TimeUnit.DAYS);
            catch (final InterruptedException e) {
                LogWriter.writeLog("Exception: " + e.getMessage());
            }
            executorService = null;
        }
    }

    /**
     * Creates a base64 image stream
     *
     @param image :: BufferedImage to encode as base64 stream
     @return A string of the image stream
     */
    @Override
    public String createBase64ImageStream(final BufferedImage image, final ImageFileType imageFileType) {

        String stream = "";

        try {
            final ByteArrayOutputStream bos = new ByteArrayOutputStream();
            writeImageToStream(image, bos, imageFileType, compressImages);
            bos.flush();
            bos.close();

            stream = "data:" + imageFileType.getMimeType() ";base64," + Base64.getEncoder().encodeToString(bos.toByteArray());
            stream = stream.replaceAll("\r\n""");
        catch (final IOException e) {
            LogWriter.writeLog("Exception: " + e.getMessage());
        }

        return stream;
    }

    @Override
    public void cancelPage(final String dirToFlush) {
        waitForImageThreads();
        delete(new File(dirToFlush));
    }

    private static void delete(final File file) {
        if (file.isDirectory()) {
            final File[] files = file.listFiles();
            if (files != null) {
                for (final File f : files) {
                    delete(f);
                }
            }
        }
        final boolean result = file.delete();
        if (!result) {
            LogWriter.writeLog("Failed to delete " + file.getAbsolutePath());
        }
    }

}