/*
 * 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.
 */

/* $Id: Fop.java 1805173 2017-08-16 10:50:04Z ssteiner $ */

package org.apache.fop.tools.anttasks;

// Ant
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.util.List;

import org.xml.sax.SAXException;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.impl.SimpleLog;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.types.FileSet;
import org.apache.tools.ant.util.GlobPatternMapper;

import org.apache.fop.apps.FOPException;
import org.apache.fop.apps.FOUserAgent;
import org.apache.fop.apps.FopConfParser;
import org.apache.fop.apps.FopFactory;
import org.apache.fop.apps.FopFactoryBuilder;
import org.apache.fop.apps.MimeConstants;
import org.apache.fop.cli.InputHandler;

/**
 * Wrapper for FOP which allows it to be accessed from within an Ant task.
 * Accepts the inputs:
 * <ul>
 * <li>fofile - formatting objects file to be transformed</li>
 * <li>iffile - intermediate format input file to be transformed</li>
 * <li>outputFormat - MIME type of the format to generate ex. "application/pdf"</li>
 * <li>inputFormat - Input file format (like "fo","xml","if")</li>
 * <li>outfile - output filename</li>
 * <li>baseDir - directory to work from</li>
 * <li>relativebase - (true | false) control whether to use each FO's
 *      directory as base directory. false uses the baseDir parameter.</li>
 * <li>userconfig - file with user configuration (same as the "-c" command
 *      line option)</li>
 * <li>messagelevel - (error | warn | info | verbose | debug) level to output
 *      non-error messages</li>
 * <li>logFiles - Controls whether the names of the files that are processed
 *      are logged or not</li>
 * </ul>
 */
public class Fop extends Task {

    private File foFile;
    private File xmlFile;
    private File xsltFile;
    private String xsltParams;
    private List<FileSet> filesets = new java.util.ArrayList<FileSet>();
    private File outFile;
    private File outDir;
    private String format; //MIME type
    private File baseDir;
    private File userConfig;
    private int messageType = Project.MSG_VERBOSE;
    private boolean logFiles = true;
    private boolean force;
    private boolean relativebase;
    private boolean throwExceptions = true;

    /**
     * Sets the filename for the userconfig.xml.
     * @param userConfig Configuration to use
     */
    public void setUserconfig(File userConfig) {
        this.userConfig = userConfig;
    }

    /**
     * Returns the file for the userconfig.xml.
     * @return the userconfig.xml file
     */
    public File getUserconfig() {
        return this.userConfig;
    }

    /**
     * Sets the input XSL-FO file.
     * @param foFile input XSL-FO file
     */
    public void setFofile(File foFile) {
        this.foFile = foFile;
    }

    /**
     * Gets the input XSL-FO file.
     * @return input XSL-FO file
     */
    public File getFofile() {
        return foFile;
    }

    /**
     * Gets the input XML file.
     * @return the input XML file.
     */
    public File getXmlFile() {
        return xmlFile;
    }

    /**
     * Sets the input XML file.
     * @param xmlFile the input XML file.
     */
    public void setXmlFile(File xmlFile) {
        this.xmlFile = xmlFile;
    }

    /**
     * Gets the input XSLT file.
     * @return the input XSLT file.
     */
    public File getXsltFile() {
        return xsltFile;
    }

    /**
     * Sets the input XSLT file.
     * @param xsltFile the input XSLT file.
     */
    public void setXsltFile(File xsltFile) {
        this.xsltFile = xsltFile;
    }

    /**
     * Gets the XSLT parameters
     * @return the XSLT parameters
     */
    public String getXsltParams() {
        return xsltParams;
    }

    /**
     * Sets the XSLT parameters
     * @param xsltParams the XSLT parameters
     */
    public void setXsltParams(String xsltParams) {
        this.xsltParams = xsltParams;
    }

    /**
     * Adds a set of XSL-FO files (nested fileset attribute).
     * @param set a fileset
     */
    public void addFileset(FileSet set) {
        filesets.add(set);
    }

    /**
     * Returns the current list of filesets.
     * @return the filesets
     */
    public List getFilesets() {
        return this.filesets;
    }

    /**
     * Set whether to include files (external-graphics, instream-foreign-object)
     * from a path relative to the .fo file (true) or the working directory (false, default)
     * only useful for filesets
     *
     * @param relbase true if paths are relative to file.
     */
    public void setRelativebase(boolean relbase) {
        this.relativebase = relbase;
    }

    /**
     * Gets the relative base attribute
     * @return the relative base attribute
     */
    public boolean getRelativebase() {
        return relativebase;
    }

    /**
     * Set whether to check dependencies, or to always generate;
     * optional, default is false.
     *
     * @param force true if always generate.
     */
    public void setForce(boolean force) {
        this.force = force;
    }

    /**
     * Gets the force attribute
     * @return the force attribute
     */
    public boolean getForce() {
        return force;
    }

    /**
     * Sets the output file.
     * @param outFile File to output to
     */
    public void setOutfile(File outFile) {
        this.outFile = outFile;
    }

    /**
     * Gets the output file.
     * @return the output file
     */
    public File getOutfile() {
        return this.outFile;
    }

    /**
     * Sets the output directory.
     * @param outDir Directory to output to
     */
    public void setOutdir(File outDir) {
        this.outDir = outDir;
    }

    /**
     * Gets the output directory.
     * @return the output directory
     */
    public File getOutdir() {
        return this.outDir;
    }

    /**
     * Sets output format (MIME type).
     * @param format the output format
     */
    public void setFormat(String format) {
        this.format = format;
    }

    /**
     * Gets the output format (MIME type).
     * @return the output format
     */
    public String getFormat() {
        return this.format;
    }

    /**
     * Set whether exceptions are thrown.
     * default is false.
     * @param throwExceptions true if exceptions should be thrown
     */
    public void setThrowexceptions(boolean throwExceptions) {
        this.throwExceptions = throwExceptions;
    }

    /**
     * Gets the throw exceptions attribute
     * @return the throw exceptions attribute
     */
    public boolean getThrowexceptions() {
        return this.throwExceptions;
    }

    /**
     * Sets the message level to be used while processing.
     * @param messageLevel (error | warn| info | verbose | debug)
     */
    public void setMessagelevel(String messageLevel) {
        if (messageLevel.equalsIgnoreCase("info")) {
            messageType = Project.MSG_INFO;
        } else if (messageLevel.equalsIgnoreCase("verbose")) {
            messageType = Project.MSG_VERBOSE;
        } else if (messageLevel.equalsIgnoreCase("debug")) {
            messageType = Project.MSG_DEBUG;
        } else if (messageLevel.equalsIgnoreCase("err")
                 || messageLevel.equalsIgnoreCase("error")) {
            messageType = Project.MSG_ERR;
        } else if (messageLevel.equalsIgnoreCase("warn")) {
            messageType = Project.MSG_WARN;
        } else {
            log("messagelevel set to unknown value \"" + messageLevel
                + "\"", Project.MSG_ERR);
            throw new BuildException("unknown messagelevel");
        }
    }

    /**
     * Returns the message type corresponding to Project.MSG_*
     * representing the current message level.
     * @return message type
     * @see org.apache.tools.ant.Project
     */
    public int getMessageType() {
        return messageType;
    }

    /**
     * Sets the base directory for single FO file (non-fileset) usage
     * @param baseDir File to use as a working directory
     */
    public void setBasedir(File baseDir) {
        this.baseDir = baseDir;
    }

    /**
     * Gets the base directory.
     * @return the base directory
     */
    public File getBasedir() {
        return (baseDir != null) ? baseDir : getProject().resolveFile(".");
    }

    /**
     * Controls whether the filenames of the files that are processed are logged
     * or not.
     * @param logFiles True if the feature should be enabled
     */
    public void setLogFiles(boolean logFiles) {
        this.logFiles = logFiles;
    }

    /**
     * Returns True if the filename of each file processed should be logged.
     * @return True if the filenames should be logged.
     */
    public boolean getLogFiles() {
        return this.logFiles;
    }

    /**
     * {@inheritDoc}
     */
    public void execute() throws BuildException {
        int logLevel = SimpleLog.LOG_LEVEL_INFO;
        switch (getMessageType()) {
            case Project.MSG_DEBUG  : logLevel = SimpleLog.LOG_LEVEL_DEBUG; break;
            case Project.MSG_INFO   : logLevel = SimpleLog.LOG_LEVEL_INFO; break;
            case Project.MSG_WARN   : logLevel = SimpleLog.LOG_LEVEL_WARN; break;
            case Project.MSG_ERR    : logLevel = SimpleLog.LOG_LEVEL_ERROR; break;
            case Project.MSG_VERBOSE: logLevel = SimpleLog.LOG_LEVEL_DEBUG; break;
            default: logLevel = SimpleLog.LOG_LEVEL_INFO;
        }
        SimpleLog logger = new SimpleLog("FOP/Anttask");
        logger.setLevel(logLevel);
        try {
            FOPTaskStarter starter = new FOPTaskStarter(this);
            starter.setLogger(logger);
            starter.run();
        } catch (FOPException ex) {
            throw new BuildException(ex);
        } catch (IOException ioe) {
            throw new BuildException(ioe);
        } catch (SAXException saxex) {
            throw new BuildException(saxex);
        }

    }

}

class FOPTaskStarter {

    // configure fopFactory as desired
    private final FopFactory fopFactory;

    private Fop task;
    private URI baseUri;

    /**
     * logging instance
     */
    protected Log logger;


    /**
     * Sets the Commons-Logging instance for this class
     * @param logger The Commons-Logging instance
     */
    public void setLogger(Log logger) {
        this.logger = logger;
    }

    /**
     * Returns the Commons-Logging instance for this class
     * @return  The Commons-Logging instance
     */
    protected Log getLogger() {
        return logger;
    }

    FOPTaskStarter(Fop task) throws SAXException, IOException {
        this.task = task;
        //Set base directory
        if (task.getBasedir() != null) {
            this.baseUri = task.getBasedir().toURI();
        } else {
            if (task.getFofile() != null) {
                this.baseUri = task.getFofile().getParentFile().toURI();
            }
        }
        if (task.getUserconfig() != null) {
            FopFactoryBuilder confBuilder = new FopConfParser(
                    task.getUserconfig()).getFopFactoryBuilder();
            confBuilder.setBaseURI(baseUri);
            fopFactory = confBuilder.build();
        } else {
            fopFactory = FopFactory.newInstance(baseUri);
        }
    }

    private static final String[][] SHORT_NAMES = {
        {"pdf",  MimeConstants.MIME_PDF},
        {"ps",   MimeConstants.MIME_POSTSCRIPT},
        {"mif",  MimeConstants.MIME_MIF},
        {"rtf",  MimeConstants.MIME_RTF},
        {"pcl",  MimeConstants.MIME_PCL},
        {"txt",  MimeConstants.MIME_PLAIN_TEXT},
        {"at",   MimeConstants.MIME_FOP_AREA_TREE},
        {"xml",  MimeConstants.MIME_FOP_AREA_TREE},
        {"tiff", MimeConstants.MIME_TIFF},
        {"tif",  MimeConstants.MIME_TIFF},
        {"png",  MimeConstants.MIME_PNG},
        {"afp",  MimeConstants.MIME_AFP}
    };

    private String normalizeOutputFormat(String format) {
        if (format == null) {
            return MimeConstants.MIME_PDF;
        }
        for (String[] shortName : SHORT_NAMES) {
            if (shortName[0].equals(format)) {
                return shortName[1];
            }
        }
        return format; //no change
    }

    private static final String[][] EXTENSIONS = {
        {MimeConstants.MIME_FOP_AREA_TREE,   ".at.xml"},
        {MimeConstants.MIME_FOP_AWT_PREVIEW, null},
        {MimeConstants.MIME_FOP_PRINT,       null},
        {MimeConstants.MIME_PDF,             ".pdf"},
        {MimeConstants.MIME_POSTSCRIPT,      ".ps"},
        {MimeConstants.MIME_PCL,             ".pcl"},
        {MimeConstants.MIME_PCL_ALT,         ".pcl"},
        {MimeConstants.MIME_PLAIN_TEXT,      ".txt"},
        {MimeConstants.MIME_RTF,             ".rtf"},
        {MimeConstants.MIME_RTF_ALT1,        ".rtf"},
        {MimeConstants.MIME_RTF_ALT2,        ".rtf"},
        {MimeConstants.MIME_MIF,             ".mif"},
        {MimeConstants.MIME_SVG,             ".svg"},
        {MimeConstants.MIME_PNG,             ".png"},
        {MimeConstants.MIME_JPEG,            ".jpg"},
        {MimeConstants.MIME_TIFF,            ".tif"},
        {MimeConstants.MIME_AFP,             ".afp"},
        {MimeConstants.MIME_AFP_ALT,         ".afp"},
        {MimeConstants.MIME_XSL_FO,          ".fo"}
    };

    private String determineExtension(String outputFormat) {
        for (String[] extension : EXTENSIONS) {
            if (extension[0].equals(outputFormat)) {
                String ext = extension[1];
                if (ext == null) {
                    throw new RuntimeException("Output format '"
                            + outputFormat + "' does not produce a file.");
                } else {
                    return ext;
                }
            }
        }
        return ".unk"; //unknown
    }

    private File replaceExtension(File file, String expectedExt,
                                  String newExt) {
        String name = file.getName();
        if (name.toLowerCase().endsWith(expectedExt)) {
            name = name.substring(0, name.length() - expectedExt.length());
        }
        name = name.concat(newExt);
        return new File(file.getParentFile(), name);
    }

    public void run() throws FOPException {
        task.log("Using base URI: " + baseUri, Project.MSG_DEBUG);

        String outputFormat = normalizeOutputFormat(task.getFormat());
        String newExtension = determineExtension(outputFormat);

        // actioncount = # of fofiles actually processed through FOP
        int actioncount = 0;
        // skippedcount = # of fofiles which haven't changed (force = "false")
        int skippedcount = 0;

        // deal with single source file
        if (task.getFofile() != null) {
            if (task.getFofile().exists()) {
                File outf = task.getOutfile();
                if (outf == null) {
                    throw new BuildException("outfile is required when fofile is used");
                }
                if (task.getOutdir() != null) {
                    outf = new File(task.getOutdir(), outf.getName());
                }
                // Render if "force" flag is set OR
                // OR output file doesn't exist OR
                // output file is older than input file
                if (task.getForce() || !outf.exists()
                    || (task.getFofile().lastModified() > outf.lastModified())) {
                    render(task.getFofile(), outf, outputFormat);
                    actioncount++;
                } else if (outf.exists()
                        && (task.getFofile().lastModified() <= outf.lastModified())) {
                    skippedcount++;
                }
            }
        } else if (task.getXmlFile() != null && task.getXsltFile() != null) {
            if (task.getXmlFile().exists() && task.getXsltFile().exists()) {
                File outf = task.getOutfile();
                if (outf == null) {
                    throw new BuildException("outfile is required when fofile is used");
                }
                if (task.getOutdir() != null) {
                    outf = new File(task.getOutdir(), outf.getName());
                }
                // Render if "force" flag is set OR
                // OR output file doesn't exist OR
                // output file is older than input file
                if (task.getForce() || !outf.exists()
                        || (task.getXmlFile().lastModified() > outf.lastModified()
                        || task.getXsltFile().lastModified() > outf.lastModified())) {
                    render(task.getXmlFile(), task.getXsltFile(), outf, outputFormat);
                    actioncount++;
                } else if (outf.exists()
                        && (task.getXmlFile().lastModified() <= outf.lastModified()
                            || task.getXsltFile().lastModified() <= outf.lastModified())) {
                    skippedcount++;
                }
            }
        }

        GlobPatternMapper mapper = new GlobPatternMapper();

        String inputExtension = ".fo";
        File xsltFile = task.getXsltFile();
        if (xsltFile != null) {
            inputExtension = ".xml";
        }
        mapper.setFrom("*" + inputExtension);
        mapper.setTo("*" + newExtension);

        // deal with the filesets
        for (int i = 0; i < task.getFilesets().size(); i++) {
            FileSet fs = (FileSet) task.getFilesets().get(i);
            DirectoryScanner ds = fs.getDirectoryScanner(task.getProject());
            String[] files = ds.getIncludedFiles();

            for (String file : files) {
                File f = new File(fs.getDir(task.getProject()), file);

                File outf = null;
                if (task.getOutdir() != null && file.endsWith(inputExtension)) {
                    String[] sa = mapper.mapFileName(file);
                    outf = new File(task.getOutdir(), sa[0]);
                } else {
                    outf = replaceExtension(f, inputExtension, newExtension);
                    if (task.getOutdir() != null) {
                        outf = new File(task.getOutdir(), outf.getName());
                    }
                }
                File dir = outf.getParentFile();
                if (!dir.exists()) {
                    dir.mkdirs();
                }
                try {
                    if (task.getRelativebase()) {
                        this.baseUri = f.getParentFile().toURI();
                    }
                    if (this.baseUri == null) {
                        this.baseUri = fs.getDir(task.getProject()).toURI();
                    }

                } catch (Exception e) {
                    task.log("Error setting base URL", Project.MSG_DEBUG);
                }

                // Render if "force" flag is set OR
                // OR output file doesn't exist OR
                // output file is older than input file
                if (task.getForce() || !outf.exists()
                        || (f.lastModified() > outf.lastModified())) {
                    if (xsltFile != null) {
                        render(f, xsltFile, outf, outputFormat);
                    } else {
                        render(f, outf, outputFormat);
                    }
                    actioncount++;
                } else if (outf.exists() && (f.lastModified() <= outf.lastModified())) {
                    skippedcount++;
                }
            }
        }

        if (actioncount + skippedcount == 0) {
            task.log("No files processed. No files were selected by the filesets "
                + "and no fofile was set." , Project.MSG_WARN);
        } else if (skippedcount > 0) {
            task.log(skippedcount + " xslfo file(s) skipped (no change found"
                + " since last generation; set force=\"true\" to override)."
                , Project.MSG_INFO);
        }
    }

    private void renderInputHandler(InputHandler inputHandler, File outFile, String outputFormat)
            throws Exception {
        OutputStream out = null;
        try {
            out = new java.io.FileOutputStream(outFile);
            out = new BufferedOutputStream(out);
        } catch (Exception ex) {
            throw new BuildException("Failed to open " + outFile, ex);
        }
        boolean success = false;
        try {
            FOUserAgent userAgent = fopFactory.newFOUserAgent();
            inputHandler.renderTo(userAgent, outputFormat, out);
            success = true;
        } catch (Exception ex) {
            if (task.getThrowexceptions()) {
                throw new BuildException(ex);
            }
            throw ex;
        } finally {
            try {
                out.close();
            } catch (IOException ioe) {
                logger.error("Error closing output file", ioe);
            }
            if (!success) {
                outFile.delete();
            }
        }
    }

    private void render(File foFile, File outFile,
                        String outputFormat) throws FOPException {
        InputHandler inputHandler = new InputHandler(foFile);
        try {
            renderInputHandler(inputHandler, outFile, outputFormat);
        } catch (Exception ex) {
            logger.error("Error rendering fo file: " + foFile, ex);
        }
        if (task.getLogFiles()) {
            task.log(foFile + " -> " + outFile, Project.MSG_INFO);
        }
    }

    private void render(File xmlFile, File xsltFile, File outFile, String outputFormat) {
        //TODO: implement support for XSLT params
        InputHandler inputHandler = new InputHandler(xmlFile, xsltFile, null);
        try {
            renderInputHandler(inputHandler, outFile, outputFormat);
        } catch (Exception ex) {
            logger.error("Error rendering xml/xslt files: "
                         + xmlFile + ", " + xsltFile, ex);
        }
        if (task.getLogFiles()) {
            task.log("xml: " + xmlFile + ", xslt: "
                     + xsltFile + " -> " + outFile, Project.MSG_INFO);
        }
    }
}

