/*
 *  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
 *
 *      https://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.tools.ant.taskdefs.optional;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import java.util.Vector;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.taskdefs.LogOutputStream;
import org.apache.tools.ant.types.EnumeratedAttribute;
import org.apache.tools.ant.types.PropertySet;
import org.apache.tools.ant.util.DOMElementWriter;
import org.apache.tools.ant.util.JavaEnvUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

/**
 * Displays all the current properties in the build. The output can be sent to
 * a file if desired.
 * <p>
 * Attribute "destfile" defines a file to send the properties to. This can be
 * processed as a standard property file later.
 * </p>
 * <p>
 * Attribute "prefix" defines a prefix which is used to filter the properties
 * only those properties starting with this prefix will be echoed.
 * </p>
 * <p>
 * By default, the "failonerror" attribute is enabled. If an error occurs while
 * writing the properties to a file, and this attribute is enabled, then a
 * BuildException will be thrown. If disabled, then IO errors will be reported
 * as a log statement, but no error will be thrown.
 * </p>
 * <p>Examples:</p><pre>
 *  &lt;echoproperties  /&gt;
 * </pre>
 * Report the current properties to the log.
 * <pre>
 *  &lt;echoproperties destfile="my.properties" /&gt;
 * </pre>
 * Report the current properties to the file "my.properties", and will
 * fail the build if the file could not be created or written to.
 * <pre>
 *  &lt;echoproperties destfile="my.properties" failonerror="false"
 *      prefix="ant" /&gt;
 * </pre>
 * Report all properties beginning with 'ant' to the file
 * "my.properties", and will log a message if the file could not be created or
 * written to, but will still allow the build to continue.
 *
 *@since      Ant 1.5
 */
public class EchoProperties extends Task {

    /**
     * the properties element.
     */
    private static final String PROPERTIES = "properties";

    /**
     * the property element.
     */
    private static final String PROPERTY = "property";

    /**
     * name attribute for property, testcase and testsuite elements.
     */
    private static final String ATTR_NAME = "name";

    /**
     * value attribute for property elements.
     */
    private static final String ATTR_VALUE = "value";

    /**
     * the input file.
     */
    private File inFile = null;

    /**
     * File object pointing to the output file. If this is null, then
     * we output to the project log, not to a file.
     */
    private File destfile = null;

    /**
     * If this is true, then errors generated during file output will become
     * build errors, and if false, then such errors will be logged, but not
     * thrown.
     */
    private boolean failonerror = true;

    private List<PropertySet> propertySets = new Vector<>();

    private String format = "text";

    private String prefix;

    /**
     * @since Ant 1.7
     */
    private String regex;

    /**
     * Sets the input file.
     *
     * @param file  the input file
     */
    public void setSrcfile(File file) {
        inFile = file;
    }

    /**
     *  Set a file to store the property output.  If this is never specified,
     *  then the output will be sent to the Ant log.
     *
     * @param destfile file to store the property output
     */
    public void setDestfile(File destfile) {
        this.destfile = destfile;
    }

    /**
     * If true, the task will fail if an error occurs writing the properties
     * file, otherwise errors are just logged.
     *
     * @param failonerror  <code>true</code> if IO exceptions are reported as build
     *      exceptions, or <code>false</code> if IO exceptions are ignored.
     */
    public void setFailOnError(boolean failonerror) {
        this.failonerror = failonerror;
    }

    /**
     * If the prefix is set, then only properties which start with this
     * prefix string will be recorded. If regex is not set and  if this
     * is never set, or it is set to an empty string or <code>null</code>,
     * then all properties will be recorded.
     *
     * <p>For example, if the attribute is set as:</p>
     * <pre>&lt;echoproperties  prefix="ant." /&gt;</pre>
     * then the property "ant.home" will be recorded, but "ant-example"
     * will not.
     *
     * @param  prefix  The new prefix value
     */
    public void setPrefix(String prefix) {
        if (prefix != null && !prefix.isEmpty()) {
            this.prefix = prefix;
            PropertySet ps = new PropertySet();
            ps.setProject(getProject());
            ps.appendPrefix(prefix);
            addPropertyset(ps);
        }
    }

    /**
     * If the regex is set, then only properties whose names match it
     * will be recorded.  If prefix is not set and if this is never set,
     * or it is set to an empty string or <code>null</code>, then all
     * properties will be recorded.
     *
     * <p>For example, if the attribute is set as:</p>
     * <pre>&lt;echoproperties  prefix=".*ant.*" /&gt;</pre>
     * then the properties "ant.home" and "user.variant" will be recorded,
     * but "ant-example" will not.
     *
     * @param  regex  The new regex value
     *
     * @since Ant 1.7
     */
    public void setRegex(String regex) {
        if (regex != null && !regex.isEmpty()) {
            this.regex = regex;
            PropertySet ps = new PropertySet();
            ps.setProject(getProject());
            ps.appendRegex(regex);
            addPropertyset(ps);
        }
    }

    /**
     * A set of properties to write.
     * @param ps the property set to write
     * @since Ant 1.6
     */
    public void addPropertyset(PropertySet ps) {
        propertySets.add(ps);
    }

    /**
     * Set the output format - xml or text.
     * @param ea an enumerated <code>FormatAttribute</code> value
     */
    public void setFormat(FormatAttribute ea) {
        format = ea.getValue();
    }

    /**
     * A enumerated type for the format attribute.
     * The values are "xml" and "text".
     */
    public static class FormatAttribute extends EnumeratedAttribute {
        private String[] formats = new String[] {"xml", "text"};

        /**
         * @see EnumeratedAttribute#getValues()
         * @return accepted values
         */
        @Override
        public String[] getValues() {
            return formats;
        }
    }

    /**
     * Run the task.
     *
     * @exception BuildException  trouble, probably file IO
     */
    @Override
    public void execute() throws BuildException {
        if (prefix != null && regex != null) {
            throw new BuildException(
                "Please specify either prefix or regex, but not both",
                getLocation());
        }
        //copy the properties file
        Hashtable<Object, Object> allProps = new Hashtable<>();

        /* load properties from file if specified, otherwise
        use Ant's properties */
        if (inFile == null && propertySets.isEmpty()) {
            // add ant properties
            allProps.putAll(getProject().getProperties());
        } else if (inFile != null) {
            if (inFile.isDirectory()) {
                String message = "srcfile is a directory!";
                if (failonerror) {
                    throw new BuildException(message, getLocation());
                }
                log(message, Project.MSG_ERR);
                return;
            }

            if (inFile.exists() && !inFile.canRead()) {
                String message = "Can not read from the specified srcfile!";
                if (failonerror) {
                    throw new BuildException(message, getLocation());
                } else {
                    log(message, Project.MSG_ERR);
                }
                return;
            }

            try (InputStream in = Files.newInputStream(inFile.toPath())) {
                Properties props = new Properties();
                props.load(in);
                allProps.putAll(props);
            } catch (FileNotFoundException fnfe) {
                String message =
                    "Could not find file " + inFile.getAbsolutePath();
                if (failonerror) {
                    throw new BuildException(message, fnfe, getLocation());
                }
                log(message, Project.MSG_WARN);
                return;
            } catch (IOException ioe) {
                String message =
                    "Could not read file " + inFile.getAbsolutePath();
                if (failonerror) {
                    throw new BuildException(message, ioe, getLocation());
                }
                log(message, Project.MSG_WARN);
                return;
            }
        }

        propertySets.stream().map(PropertySet::getProperties)
            .forEach(allProps::putAll);

        try (OutputStream os = createOutputStream()) {
            if (os != null) {
                saveProperties(allProps, os);
            }
        } catch (IOException ioe) {
            if (failonerror) {
                throw new BuildException(ioe, getLocation());
            }
            log(ioe.getMessage(), Project.MSG_INFO);
        }
    }

    /**
     * Send the key/value pairs in the hashtable to the given output stream.
     * Only those properties matching the <code>prefix</code> constraint will be
     * sent to the output stream.
     * The output stream will be closed when this method returns.
     *
     * @param  allProps         propfile to save
     * @param  os               output stream
     * @throws IOException      on output errors
     * @throws BuildException   on other errors
     */
    protected void saveProperties(Hashtable<Object, Object> allProps, OutputStream os)
        throws IOException, BuildException {
        final List<Object> keyList = new ArrayList<>(allProps.keySet());

        Properties props = new Properties() {
            private static final long serialVersionUID = 5090936442309201654L;

            @Override
            public Enumeration<Object> keys() {
                return keyList.stream()
                    .sorted(Comparator.comparing(Object::toString))
                    .collect(Collectors.collectingAndThen(Collectors.toList(),
                        Collections::enumeration));
            }

            @Override
            public Set<Map.Entry<Object, Object>> entrySet() {
                Set<Map.Entry<Object, Object>> t =
                    new TreeSet<>(Comparator.comparing(
                        ((Function<Map.Entry<Object, Object>, Object>) Map.Entry::getKey)
                            .andThen(Object::toString)));
                t.addAll(super.entrySet());
                return t;
            }
        };
        allProps.forEach((k, v) -> props.put(String.valueOf(k), String.valueOf(v)));

        if ("text".equals(format)) {
            jdkSaveProperties(props, os, "Ant properties");
        } else if ("xml".equals(format)) {
            xmlSaveProperties(props, os);
        }
    }

    /**
     * a tuple for the sort list.
     */
    private static final class Tuple implements Comparable<Tuple> {
        private String key;
        private String value;

        private Tuple(String key, String value) {
            this.key = key;
            this.value = value;
        }

        /**
         * Compares this object with the specified object for order.
         * @param o the Object to be compared.
         * @return a negative integer, zero, or a positive integer as this object is
         *         less than, equal to, or greater than the specified object.
         * @throws ClassCastException if the specified object's type prevents it
         *                            from being compared to this Object.
         */
        @Override
        public int compareTo(Tuple o) {
            return Comparator.<String> naturalOrder().compare(key, o.key);
        }

        @Override
        public boolean equals(Object o) {
            if (o == this) {
                return true;
            }
            if (o == null || o.getClass() != getClass()) {
                return false;
            }
            Tuple that = (Tuple) o;
            return Objects.equals(key, that.key)
                && Objects.equals(value, that.value);
        }

        @Override
        public int hashCode() {
            return Objects.hash(key);
        }
    }

    private List<Tuple> sortProperties(Properties props) {
        // sort the list. Makes SCM and manual diffs easier.
        return props.stringPropertyNames().stream()
            .map(k -> new Tuple(k, props.getProperty(k))).sorted()
            .collect(Collectors.toList());
    }

    /**
     * Output the properties as xml output.
     * @param props the properties to save
     * @param os    the output stream to write to (Note this gets closed)
     * @throws IOException on error in writing to the stream
     */
    protected void xmlSaveProperties(Properties props,
                                     OutputStream os) throws IOException {
        // create XML document
        Document doc = getDocumentBuilder().newDocument();
        Element rootElement = doc.createElement(PROPERTIES);

        List<Tuple> sorted = sortProperties(props);

        // output properties
        for (Tuple tuple : sorted) {
            Element propElement = doc.createElement(PROPERTY);
            propElement.setAttribute(ATTR_NAME, tuple.key);
            propElement.setAttribute(ATTR_VALUE, tuple.value);
            rootElement.appendChild(propElement);
        }

        try (Writer wri = new OutputStreamWriter(os, StandardCharsets.UTF_8)) {
            wri.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
            new DOMElementWriter().write(rootElement, wri, 0, "\t");
            wri.flush();
        } catch (IOException ioe) {
            throw new BuildException("Unable to write XML file", ioe);
        }
    }

    /**
     * JDK 1.2 allows for the safer method
     * <code>Properties.store(OutputStream, String)</code>, which throws an
     * <code>IOException</code> on an output error.
     *
     * @param props the properties to record
     * @param os record the properties to this output stream
     * @param header prepend this header to the property output
     * @exception IOException on an I/O error during a write.
     */
    protected void jdkSaveProperties(Properties props, OutputStream os,
                                     String header) throws IOException {
       try {
           props.store(os, header);
       } catch (IOException ioe) {
           throw new BuildException(ioe, getLocation());
       } finally {
           if (os != null) {
               try {
                   os.close();
               } catch (IOException ioex) {
                   log("Failed to close output stream");
               }
           }
       }
    }

    private OutputStream createOutputStream() throws IOException {
        if (destfile == null) {
            return new LogOutputStream(this);
        }
        if (destfile.exists() && destfile.isDirectory()) {
            String message = "destfile is a directory!";
            if (failonerror) {
                throw new BuildException(message, getLocation());
            }
            log(message, Project.MSG_ERR);
            return null;
        }
        if (destfile.exists() && !destfile.canWrite()) {
            String message =
                "Can not write to the specified destfile!";
            if (failonerror) {
                throw new BuildException(message, getLocation());
            }
            log(message, Project.MSG_ERR);
            return null;
        }
        return Files.newOutputStream(this.destfile.toPath());
    }

    /**
     * Uses the DocumentBuilderFactory to get a DocumentBuilder instance.
     *
     * @return The DocumentBuilder instance
     */
    private static DocumentBuilder getDocumentBuilder() {
        try {
            return DocumentBuilderFactory.newInstance().newDocumentBuilder();
        } catch (Exception e) {
            throw new ExceptionInInitializerError(e);
        }
    }
}
