/*
 *  ====================================================================
 *    Licensed to the Apache Software Foundation (ASF) under one or more
 *    contributor license agreements.  See the NOTICE file distributed with
 *    this work for additional information regarding copyright ownership.
 *    The ASF licenses this file to You under the Apache License, Version 2.0
 *    (the "License"); you may not use this file except in compliance with
 *    the License.  You may obtain a copy of the License at
 *
 *        http://www.apache.org/licenses/LICENSE-2.0
 *
 *    Unless required by applicable law or agreed to in writing, software
 *    distributed under the License is distributed on an "AS IS" BASIS,
 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *    See the License for the specific language governing permissions and
 *    limitations under the License.
 * ====================================================================
 */

package org.apache.poi.xslf.util;

import java.awt.AlphaComposite;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Dimension2D;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.apache.logging.log4j.Logger;
import org.apache.poi.logging.PoiLogManager;
import org.apache.poi.common.usermodel.GenericRecord;
import org.apache.poi.poifs.filesystem.FileMagic;
import org.apache.poi.sl.draw.Drawable;
import org.apache.poi.sl.draw.EmbeddedExtractor.EmbeddedPart;
import org.apache.poi.util.Dimension2DDouble;
import org.apache.poi.util.GenericRecordJsonWriter;
import org.apache.poi.util.LocaleUtil;

/**
 * An utility to convert slides of a .pptx slide show to a PNG image
 */
public final class PPTX2PNG {
    private static final Logger LOG = PoiLogManager.getLogger(PPTX2PNG.class);

    private static final String INPUT_PAT_REGEX =
        "(?<slideno>[^|]+)\\|(?<format>[^|]+)\\|(?<basename>.+)\\.(?<ext>[^.]++)";

    private static final Pattern INPUT_PATTERN = Pattern.compile(INPUT_PAT_REGEX);

    private static final String OUTPUT_PAT_REGEX = "${basename}-${slideno}.${format}";

    private static void usage(String error){
        String msg =
            "Usage: PPTX2PNG [options] <.ppt/.pptx/.emf/.wmf file or 'stdin'>\n" +
            (error == null ? "" : ("Error: "+error+"\n")) +
            "Options:\n" +
            "    -scale <float>    scale factor\n" +
            "    -fixSide <side>   specify side (long,short,width,height) to fix - use <scale> as amount of pixels\n" +
            "    -slide <integer>  1-based index of a slide to render\n" +
            "    -format <type>    png,gif,jpg,svg,pdf (,log,null for testing)\n" +
            "    -outdir <dir>     output directory, defaults to origin of the ppt/pptx file\n" +
            "    -outfile <file>   output filename, defaults to '"+OUTPUT_PAT_REGEX+"'\n" +
            "    -outpat <pattern> output filename pattern, defaults to '"+OUTPUT_PAT_REGEX+"'\n" +
            "                      patterns: basename, slideno, format, ext\n" +
            "    -dump <file>      dump the annotated records to a file\n" +
            "    -quiet            do not write to console (for normal processing)\n" +
            "    -ignoreParse      ignore parsing error and continue with the records read until the error\n" +
            "    -extractEmbedded  extract embedded parts\n" +
            "    -inputType <type> default input file type (OLE2,WMF,EMF), default is OLE2 = Powerpoint\n" +
            "                      some files (usually wmf) don't have a header, i.e. an identifiable file magic\n" +
            "    -textAsShapes     text elements are saved as shapes in SVG, necessary for variable spacing\n" +
            "                      often found in math formulas\n" +
            "    -charset <cs>     sets the default charset to be used, defaults to Windows-1252\n" +
            "    -emfHeaderBounds  force the usage of the emf header bounds to calculate the bounding box\n" +
            "    -fontdir <dir>    (PDF only) font directories separated by \";\" - use $HOME for current users home dir\n" +
            "                      defaults to the usual plattform directories\n" +
            "    -fontTtf <regex>  (PDF only) regex to match the .ttf filenames\n" +
            "    -fontMap <map>    \";\"-separated list of font mappings <typeface from>:<typeface to>";

        System.out.println(msg);
        // no System.exit here, as we also run in junit tests!
    }

    public static void main(String[] args) throws Exception {
        PPTX2PNG p2p = new PPTX2PNG();

        if (p2p.parseCommandLine(args)) {
            p2p.processFile();
        }
    }

    private String slidenumStr = "-1";
    private float scale = 1;
    private File file = null;
    private String format = "png";
    private File outdir = null;
    private String outfile = null;
    private boolean quiet = false;
    private String outPattern = OUTPUT_PAT_REGEX;
    private File dumpfile = null;
    private String fixSide = "scale";
    private boolean ignoreParse = false;
    private boolean extractEmbedded = false;
    private FileMagic defaultFileType = FileMagic.OLE2;
    private boolean textAsShapes = false;
    private Charset charset = LocaleUtil.CHARSET_1252;
    private boolean emfHeaderBounds = false;
    private String fontDir = null;
    private String fontTtf = null;
    private String fontMap = null;

    private PPTX2PNG() {
    }

    @SuppressWarnings("AssignmentToForLoopParameter")
    private boolean parseCommandLine(String[] args) {
        if (args.length == 0) {
            usage(null);
            return false;
        }

        for (int i = 0; i < args.length; i++) {
            String opt = (i+1 < args.length) ? args[i+1] : null;
            switch (args[i].toLowerCase(Locale.ROOT)) {
                case "-scale":
                    if (opt != null) {
                        scale = Float.parseFloat(opt);
                        i++;
                    }
                    break;
                case "-slide":
                    slidenumStr = opt;
                    i++;
                    break;
                case "-format":
                    format = opt;
                    i++;
                    break;
                case "-outdir":
                    if (opt != null) {
                        outdir = new File(opt);
                        i++;
                    }
                    break;
                case "-outfile":
                    outfile = opt;
                    i++;
                    break;
                case "-outpat":
                    outPattern = opt;
                    i++;
                    break;
                case "-quiet":
                    quiet = true;
                    break;
                case "-dump":
                    if (opt != null) {
                        dumpfile = new File(opt);
                        i++;
                    } else {
                        dumpfile = new File("pptx2png.dump");
                    }
                    break;
                case "-fixside":
                    if (opt != null) {
                        fixSide = opt.toLowerCase(Locale.ROOT);
                        i++;
                    } else {
                        fixSide = "long";
                    }
                    break;
                case "-inputtype":
                    if (opt != null) {
                        defaultFileType = FileMagic.valueOf(opt);
                        i++;
                    } else {
                        defaultFileType = FileMagic.OLE2;
                    }
                    break;
                case "-textasshapes":
                    textAsShapes = true;
                    break;
                case "-ignoreparse":
                    ignoreParse = true;
                    break;
                case "-extractembedded":
                    extractEmbedded = true;
                    break;
                case "-charset":
                    if (opt != null) {
                        charset = Charset.forName(opt);
                        i++;
                    } else {
                        charset = LocaleUtil.CHARSET_1252;
                    }
                    break;
                case "-emfheaderbounds":
                    emfHeaderBounds = true;
                    break;
                case "-fontdir":
                    if (opt != null) {
                        fontDir = opt;
                        i++;
                    } else {
                        fontDir = null;
                    }
                    break;
                case "-fontttf":
                    if (opt != null) {
                        fontTtf = opt;
                        i++;
                    } else {
                        fontTtf = null;
                    }
                    break;
                case "-fontmap":
                    if (opt != null) {
                        fontMap = opt;
                        i++;
                    }  else {
                        fontMap = null;
                    }
                    break;
                default:
                    file = new File(args[i]);
                    break;
            }
        }

        final boolean isStdin = file != null && "stdin".equalsIgnoreCase(file.getName());

        if (!isStdin && (file == null || !file.exists())) {
            usage("File not specified or it doesn't exist");
            return false;
        }

        if (format == null || !format.matches("^(png|gif|jpg|null|svg|pdf|log)$")) {
            usage("Invalid format given");
            return false;
        }

        if (outdir == null) {
            if (isStdin) {
                usage("When reading from STDIN, you need to specify an outdir.");
                return false;
            } else {
                outdir = file.getAbsoluteFile().getParentFile();
            }
        }
        if (!outdir.exists()) {
            usage("Outdir doesn't exist");
            return false;
        }

        if (!"null".equals(format) && (outdir == null || !outdir.exists() || !outdir.isDirectory())) {
            usage("Output directory doesn't exist");
            return false;
        }

        if (scale < 0) {
            usage("Invalid scale given");
            return false;
        }

        if (!"long,short,width,height,scale".contains(fixSide)) {
            usage("<fixside> must be one of long / short / width / height / scale");
            return false;
        }

        return true;
    }

    private void processFile() throws IOException {
        if (!quiet) {
            System.out.println("Processing " + file);
        }



        try (MFProxy proxy = initProxy(file)) {
            final Set<Integer> slidenum = proxy.slideIndexes(slidenumStr);
            if (slidenum.isEmpty()) {
                usage("slidenum must be either -1 (for all) or within range: [1.." + proxy.getSlideCount() + "] for " + file);
                return;
            }

            final Dimension2D dim = new Dimension2DDouble();
            final double lenSide = getDimensions(proxy, dim);
            final int width = Math.max((int)Math.rint(dim.getWidth()),1);
            final int height = Math.max((int)Math.rint(dim.getHeight()),1);

            try (OutputFormat outputFormat = getOutput()) {
                for (int slideNo : slidenum) {
                    proxy.setSlideNo(slideNo);
                    if (!quiet) {
                        String title = proxy.getTitle();
                        System.out.println("Rendering slide " + slideNo + (title == null ? "" : ": " + title.trim()));
                    }

                    dumpRecords(proxy);

                    extractEmbedded(proxy, slideNo);

                    Graphics2D graphics = outputFormat.addSlide(width, height);

                    // default rendering options
                    graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
                    graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
                    graphics.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
                    graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
                    graphics.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
                    graphics.setRenderingHint(Drawable.DEFAULT_CHARSET, getDefaultCharset());
                    graphics.setRenderingHint(Drawable.EMF_FORCE_HEADER_BOUNDS, emfHeaderBounds);
                    if (fontMap != null) {
                        Map<String,String> fmap = Arrays.stream(fontMap.split(";"))
                            .map(s -> s.split(":"))
                            .collect(Collectors.toMap(s -> s[0], s -> s[1]));
                        graphics.setRenderingHint(Drawable.FONT_MAP, fmap);
                    }

                    graphics.scale(scale / lenSide, scale / lenSide);

                    graphics.setComposite(AlphaComposite.Clear);
                    graphics.fillRect(0, 0, width, height);
                    graphics.setComposite(AlphaComposite.SrcOver);

                    // draw stuff
                    proxy.draw(graphics);

                    outputFormat.writeSlide(proxy, new File(outdir, calcOutFile(proxy, slideNo)));
                }

                outputFormat.writeDocument(proxy, new File(outdir, calcOutFile(proxy, 0)));
            }

        } catch (NoScratchpadException e) {
            usage("'"+file.getName()+"': Format not supported - try to include poi-scratchpad.jar into the CLASSPATH.");
            return;
        }

        if (!quiet) {
            System.out.println("Done");
        }
    }

    private OutputFormat getOutput() {
        switch (format) {
            case "svg": {
                try {
                    return new SVGFormat(textAsShapes);
                } catch (Exception | NoClassDefFoundError e) {
                    LOG.atError().withThrowable(e).log("Batik is not added to/working on the module-path. Use classpath mode instead of JPMS. Fallback to PNG.");
                    return new BitmapFormat("png");
                }
            }
            case "pdf":
                return new PDFFormat(textAsShapes,fontDir,fontTtf);
            case "log":
                return new DummyFormat();
            default:
                return new BitmapFormat(format);
        }
    }

    private double getDimensions(MFProxy proxy, Dimension2D dim) {
        final Dimension2D pgsize = proxy.getSize();

        final double lenSide;
        switch (fixSide) {
            default:
            case "scale":
                lenSide = 1;
                break;
            case "long":
                lenSide = Math.max(pgsize.getWidth(), pgsize.getHeight());
                break;
            case "short":
                lenSide = Math.min(pgsize.getWidth(), pgsize.getHeight());
                break;
            case "width":
                lenSide = pgsize.getWidth();
                break;
            case "height":
                lenSide = pgsize.getHeight();
                break;
        }

        dim.setSize(pgsize.getWidth() * scale / lenSide, pgsize.getHeight() * scale / lenSide);
        return lenSide;
    }

    private void dumpRecords(MFProxy proxy) throws IOException {
        if (dumpfile == null || "null".equals(dumpfile.getPath())) {
            return;
        }
        GenericRecord gr = proxy.getRoot();
        try (GenericRecordJsonWriter fw = new GenericRecordJsonWriter(dumpfile) {
            @Override
            protected boolean printBytes(String name, Object o) {
                return false;
            }
        }) {
            if (gr == null) {
                fw.writeError(file.getName()+" doesn't support GenericRecord interface and can't be dumped to a file.");
            } else {
                fw.write(gr);
            }
        }
    }

    private void extractEmbedded(MFProxy proxy, int slideNo) throws IOException {
        if (!extractEmbedded) {
            return;
        }
        for (EmbeddedPart ep : proxy.getEmbeddings(slideNo)) {
            String filename = ep.getName();
            // do some sanitizing for creative filenames ...
            filename = new File(filename == null ? "dummy.dat" : filename).getName();
            filename = calcOutFile(proxy, slideNo).replaceFirst("\\.\\w+$", "")+"_"+filename;
            try (OutputStream fos = Files.newOutputStream(new File(outdir, filename).toPath())) {
                fos.write(ep.getData().get());
            }
        }
    }

    private interface ProxyConsumer {
        void parse(MFProxy proxy) throws IOException;
    }

    @SuppressWarnings({"resource", "squid:S2095"})
    private MFProxy initProxy(File file) throws IOException {
        MFProxy proxy;
        final String fileName = file.getName().toLowerCase(Locale.ROOT);
        FileMagic fm;
        ProxyConsumer con;
        if ("stdin".equals(fileName)) {
            InputStream bis = FileMagic.prepareToCheckMagic(System.in);
            fm = FileMagic.valueOf(bis);
            con = (p) -> p.parse(bis);
        } else {
            fm = FileMagic.valueOf(file);
            con = (p) -> p.parse(file);
        }

        if (fm == FileMagic.UNKNOWN) {
            fm = defaultFileType;
        }
        switch (fm) {
            case EMF:
                proxy = new EMFHandler();
                break;
            case WMF:
                proxy = new WMFHandler();
                break;
            default:
                proxy = new PPTHandler();
                break;
        }
        proxy.setIgnoreParse(ignoreParse);
        proxy.setQuiet(quiet);
        con.parse(proxy);
        proxy.setDefaultCharset(charset);

        return proxy;
    }

    private String calcOutFile(MFProxy proxy, int slideNo) {
        if (outfile != null) {
            return outfile;
        }

        String fileName = file.getName();
        if ("stdin".equals(fileName)) {
            fileName += ".ext";
        }

        String inname = String.format(Locale.ROOT, "%04d|%s|%s", slideNo, format, fileName);
        String outpat = (proxy.getSlideCount() > 1 && slideNo > 0 ? outPattern : outPattern.replaceAll("-?\\$\\{slideno}", ""));
        return INPUT_PATTERN.matcher(inname).replaceAll(outpat);
    }

    private Charset getDefaultCharset() {
        return charset;
    }

    static class NoScratchpadException extends IOException {
        NoScratchpadException() {
        }

        NoScratchpadException(Throwable cause) {
            super(cause);
        }
    }
}
