/*
 *  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.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.text.DateFormat;
import java.text.DecimalFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.TimeZone;
import java.util.Vector;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.types.EnumeratedAttribute;
import org.apache.tools.ant.util.DateUtils;
import org.apache.tools.ant.util.FileUtils;
import org.apache.tools.ant.util.LayoutPreservingProperties;

/**
 * Modifies settings in a property file.
 *
 * <p>The following is an example of its usage:</p>
 * <pre>
 *    &lt;target name="setState"&gt;
 *      &lt;property
 *        name="header"
 *        value="##Generated file - do not modify!"/&gt;
 *      &lt;propertyfile file="apropfile.properties" comment="${header}"&gt;
 *        &lt;entry key="product.version.major" type="int"  value="5"/&gt;
 *        &lt;entry key="product.version.minor" type="int"  value="0"/&gt;
 *        &lt;entry key="product.build.major"   type="int"  value="0" /&gt;
 *        &lt;entry key="product.build.minor"   type="int"  operation="+"/&gt;
 *        &lt;entry key="product.build.date"    type="date" value="now"/&gt;
 *        &lt;entry key="intSet" type="int" operation="=" value="681"/&gt;
 *        &lt;entry key="intDec" type="int" operation="-"/&gt;
 *        &lt;entry key="StringEquals" type="string" value="testValue"/&gt;
 *     &lt;/propertyfile&gt;
 *   &lt;/target&gt;
 * </pre>
 * <p>
 * The &lt;propertyfile&gt; task must have:
 * </p>
 * <ul>
 *   <li>file</li>
 * </ul>
 * Other parameters are:
 * <ul>
 *   <li>comment</li>
 *   <li>key</li>
 *   <li>operation</li>
 *   <li>type</li>
 *   <li>value (the final four being eliminated shortly)</li>
 * </ul>
 * <p>
 * The &lt;entry&gt; task must have:
 * </p>
 * <ul>
 *   <li>key</li>
 * </ul>
 * Other parameters are:
 * <ul>
 *   <li>operation</li>
 *   <li>type</li>
 *   <li>value</li>
 *   <li>default</li>
 *   <li>unit</li>
 * </ul>
 * <p>
 * If type is unspecified, it defaults to string.
 * </p>
 * Parameter values:
 * <dl>
 *   <dt>operation:</dt>
 *   <dd>
 *   <ul>
 *     <li>"=" (set -- default)</li>
 *     <li>"-" (dec)</li>
 *     <li>"+" (inc)</li>
 *   </ul>
 *   </dd>
 *   <dt>type:</dt>
 *   <dd>
 *   <ul>
 *     <li>"int"</li>
 *     <li>"date"</li>
 *     <li>"string"</li>
 *   </ul>
 *   </dd>
 *   <dt>value:</dt>
 *   <dd>
 *   <ul>
 *     <li>holds the default value, if the property
 *              was not found in property file</li>
 *     <li>"now" In case of type "date", the
 *              value "now" will be replaced by the current
 *              date/time and used even if a valid date was
 *              found in the property file.</li>
 *   </ul>
 *   </dd>
 * </dl>
 *
 * <p>String property types can only use the "=" operation.
 * Int property types can only use the "=", "-" or "+" operations.<p>
 *
 * The message property is used for the property file header, with "\\" being
 * a newline delimiter character.
 *
 */
public class PropertyFile extends Task {

    /* ========================================================================
     *
     * Instance variables.
     */

    // Use this to prepend a message to the properties file
    private String              comment;

    private Properties          properties;
    private File                propertyfile;
    private boolean             useJDKProperties;

    private Vector<Entry> entries = new Vector<>();

    /* ========================================================================
     *
     * Constructors
     */

    /* ========================================================================
     *
     * Methods
     */

    /**
     * Execute the task.
     * @throws BuildException on error.
     */
    @Override
    public void execute() throws BuildException {
        checkParameters();
        readFile();
        executeOperation();
        writeFile();
    }

    /**
     * The entry nested element.
     * @return an entry nested element to be configured.
     */
    public Entry createEntry() {
        Entry e = new Entry();
        entries.addElement(e);
        return e;
    }

    private void executeOperation() throws BuildException {
        entries.forEach(e -> e.executeOn(properties));
    }

    private void readFile() throws BuildException {
        if (useJDKProperties) {
            // user chose to use standard Java properties, which loose
            // comments and layout
            properties = new Properties();
        } else {
            LayoutPreservingProperties p = new LayoutPreservingProperties();
            Calendar c = Calendar.getInstance();
            c.setTime(getProject().getBuildDate());
            TimeZone tz = System.getenv(DateUtils.ENV_SOURCE_DATE_EPOCH) != null
                ? TimeZone.getTimeZone("UTC") : null;
            p.setDateComment(c, tz);
            properties = p;
        }
        try {
            if (propertyfile.exists()) {
                log("Updating property file: "
                    + propertyfile.getAbsolutePath());
                try (InputStream fis = Files.newInputStream(propertyfile.toPath());
                     BufferedInputStream bis = new BufferedInputStream(fis)) {
                    properties.load(bis);
                }
            } else {
                log("Creating new property file: "
                    + propertyfile.getAbsolutePath());
                try (OutputStream out =
                     Files.newOutputStream(propertyfile.toPath())) {
                    out.flush();
                }
            }
        } catch (IOException ioe) {
            throw new BuildException(ioe.toString());
        }
    }

    private void checkParameters() throws BuildException {
        if (!checkParam(propertyfile)) {
            throw new BuildException("file token must not be null.",
                                     getLocation());
        }
    }

    /**
     * Location of the property file to be edited; required.
     * @param file the property file.
     */
    public void setFile(File file) {
        propertyfile = file;
    }

    /**
     * optional header comment for the file
     * @param hdr the string to use for the comment.
     */
    public void setComment(String hdr) {
        comment = hdr;
    }

    /**
     * optional flag to use original Java properties (as opposed to
     * layout preserving properties)
     * @param val boolean
     */
    public void setJDKProperties(boolean val) {
        useJDKProperties = val;
    }

    private void writeFile() throws BuildException {
        // Write to RAM first, as an OOME could otherwise produce a truncated file:
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            properties.store(baos, comment);
        } catch (IOException x) { // should not happen
            throw new BuildException(x, getLocation());
        }
        try {
            try (OutputStream os = Files.newOutputStream(propertyfile.toPath())) {
                os.write(baos.toByteArray());
            } catch (IOException x) { // possibly corrupt
                FileUtils.getFileUtils().tryHardToDelete(propertyfile);
                throw x;
            }
        } catch (IOException x) { // opening, writing, or closing
            throw new BuildException(x, getLocation());
        }
    }

    private boolean checkParam(File param) {
        return param != null;
    }

    /**
     * Instance of this class represents nested elements of
     * a task propertyfile.
     */
    public static class Entry {
        private static final int DEFAULT_INT_VALUE = 0;
        private static final String DEFAULT_DATE_VALUE = "now";
        private static final String DEFAULT_STRING_VALUE = "";

        private String key = null;
        private int    type = Type.STRING_TYPE;
        private int    operation = Operation.EQUALS_OPER;
        private String value = null;
        private String defaultValue = null;
        private String newValue = null;
        private String pattern = null;
        private int    field = Calendar.DATE;

        /**
         * Name of the property name/value pair
         * @param value the key.
         */
        public void setKey(String value) {
            this.key = value;
        }

        /**
         * Value to set (=), to add (+) or subtract (-)
         * @param value the value.
         */
        public void setValue(String value) {
            this.value = value;
        }

        /**
         * operation to apply.
         * &quot;+&quot; or &quot;=&quot;
         *(default) for all datatypes; &quot;-&quot; for date and int only)\.
         * @param value the operation enumerated value.
         */
        public void setOperation(Operation value) {
            this.operation = Operation.toOperation(value.getValue());
        }

        /**
         * Regard the value as : int, date or string (default)
         * @param value the type enumerated value.
         */
        public void setType(Type value) {
            this.type = Type.toType(value.getValue());
        }

        /**
         * Initial value to set for a property if it is not
         * already defined in the property file.
         * For type date, an additional keyword is allowed: &quot;now&quot;
         * @param value the default value.
         */
        public void setDefault(String value) {
            this.defaultValue = value;
        }

        /**
         * For int and date type only. If present, Values will
         * be parsed and formatted accordingly.
         * @param value the pattern to use.
         */
        public void setPattern(String value) {
            this.pattern = value;
        }

        /**
         * The unit of the value to be applied to date +/- operations.
         *            Valid Values are:
         *            <ul>
         *               <li>millisecond</li>
         *               <li>second</li>
         *               <li>minute</li>
         *               <li>hour</li>
         *               <li>day (default)</li>
         *               <li>week</li>
         *               <li>month</li>
         *               <li>year</li>
         *            </ul>
         *            This only applies to date types using a +/- operation.
         * @param unit the unit enumerated value.
         * @since Ant 1.5
         */
        public void setUnit(PropertyFile.Unit unit) {
            field = unit.getCalendarField();
        }

        /**
         * Apply the nested element to the properties.
         * @param props the properties to apply the entry on.
         * @throws BuildException if there is an error.
         */
        protected void executeOn(Properties props) throws BuildException {
            checkParameters();

            if (operation == Operation.DELETE_OPER) {
                props.remove(key);
                return;
            }

            // type may be null because it wasn't set
            String oldValue = (String) props.get(key);
            try {
                if (type == Type.INTEGER_TYPE) {
                    executeInteger(oldValue);
                } else if (type == Type.DATE_TYPE) {
                    executeDate(oldValue);
                } else if (type == Type.STRING_TYPE) {
                    executeString(oldValue);
                } else {
                    throw new BuildException("Unknown operation type: %d", type);
                }
            } catch (NullPointerException npe) {
                // Default to string type
                // which means do nothing
                npe.printStackTrace(); //NOSONAR
            }

            if (newValue == null) {
                newValue = "";
            }

            // Insert as a string by default
            props.put(key, newValue);
        }

        /**
         * Handle operations for type <code>date</code>.
         *
         * @param oldValue the current value read from the property file or
         *                 <code>null</code> if the <code>key</code> was
         *                 not contained in the property file.
         */
        private void executeDate(String oldValue) throws BuildException {
            Calendar currentValue = Calendar.getInstance();

            if (pattern == null) {
                pattern = "yyyy/MM/dd HH:mm";
            }
            DateFormat fmt = new SimpleDateFormat(pattern);

            String currentStringValue = getCurrentValue(oldValue);
            if (currentStringValue == null) {
                currentStringValue = DEFAULT_DATE_VALUE;
            }

            if ("now".equals(currentStringValue)) {
                currentValue.setTime(new Date());
            } else {
                try {
                    currentValue.setTime(fmt.parse(currentStringValue));
                } catch (ParseException pe)  {
                    // swallow
                }
            }

            if (operation != Operation.EQUALS_OPER) {
                int offset = 0;
                try {
                    offset = Integer.parseInt(value);
                    if (operation == Operation.DECREMENT_OPER) {
                        offset = -1 * offset;
                    }
                } catch (NumberFormatException e) {
                    throw new BuildException("Value not an integer on " + key);
                }
                currentValue.add(field, offset);
            }

            newValue = fmt.format(currentValue.getTime());
        }

        /**
         * Handle operations for type <code>int</code>.
         *
         * @param oldValue the current value read from the property file or
         *                 <code>null</code> if the <code>key</code> was
         *                 not contained in the property file.
         */
        private void executeInteger(String oldValue) throws BuildException {
            int currentValue = DEFAULT_INT_VALUE;
            int newV  = DEFAULT_INT_VALUE;

            DecimalFormat fmt = (pattern != null) ? new DecimalFormat(pattern)
                : new DecimalFormat();
            try {
                String curval = getCurrentValue(oldValue);
                if (curval != null) {
                    currentValue = fmt.parse(curval).intValue();
                } else {
                    currentValue = 0;
                }
            } catch (NumberFormatException | ParseException nfe) {
                // swallow
            }

            if (operation == Operation.EQUALS_OPER) {
                newV = currentValue;
            } else {
                int operationValue = 1;
                if (value != null) {
                    try {
                        operationValue = fmt.parse(value).intValue();
                    } catch (NumberFormatException | ParseException nfe) {
                        // swallow
                    }
                }

                if (operation == Operation.INCREMENT_OPER) {
                    newV = currentValue + operationValue;
                } else if (operation == Operation.DECREMENT_OPER) {
                    newV = currentValue - operationValue;
                }
            }

            this.newValue = fmt.format(newV);
        }

        /**
         * Handle operations for type <code>string</code>.
         *
         * @param oldValue the current value read from the property file or
         *                 <code>null</code> if the <code>key</code> was
         *                 not contained in the property file.
         */
        private void executeString(String oldValue) throws BuildException {
            String newV  = DEFAULT_STRING_VALUE;

            String currentValue = getCurrentValue(oldValue);

            if (currentValue == null) {
                currentValue = DEFAULT_STRING_VALUE;
            }

            if (operation == Operation.EQUALS_OPER) {
                newV = currentValue;
            } else if (operation == Operation.INCREMENT_OPER) {
                newV = currentValue + value;
            }
            this.newValue = newV;
        }

        /**
         * Check if parameter combinations can be supported
         * @todo make sure the 'unit' attribute is only specified on date
         *      fields
         */
        private void checkParameters() throws BuildException {
            if (type == Type.STRING_TYPE
                && operation == Operation.DECREMENT_OPER) {
                throw new BuildException("- is not supported for string "
                                         + "properties (key:" + key + ")");
            }
            if (value == null && defaultValue == null  && operation != Operation.DELETE_OPER) {
                throw new BuildException(
                    "\"value\" and/or \"default\" attribute must be specified (key: %s)",
                    key);
            }
            if (key == null) {
                throw new BuildException("key is mandatory");
            }
            if (type == Type.STRING_TYPE && pattern != null) {
                throw new BuildException(
                    "pattern is not supported for string properties (key: %s)",
                    key);
            }
        }

        private String getCurrentValue(String oldValue) {
            String ret = null;
            if (operation == Operation.EQUALS_OPER) {
                // If only value is specified, the property is set to it
                // regardless of its previous value.
                if (value != null && defaultValue == null) {
                    ret = value;
                }

                // If only default is specified and the property previously
                // existed in the property file, it is unchanged.
                if (value == null && defaultValue != null && oldValue != null) {
                    ret = oldValue;
                }

                // If only default is specified and the property did not
                // exist in the property file, the property is set to default.
                if (value == null && defaultValue != null && oldValue == null) {
                    ret = defaultValue;
                }

                // If value and default are both specified and the property
                // previously existed in the property file, the property
                // is set to value.
                if (value != null && defaultValue != null && oldValue != null) {
                    ret = value;
                }

                // If value and default are both specified and the property
                // did not exist in the property file, the property is set
                // to default.
                if (value != null && defaultValue != null && oldValue == null) {
                    ret = defaultValue;
                }
            } else {
                ret = oldValue == null ? defaultValue : oldValue;
            }

            return ret;
        }

        /**
         * Enumerated attribute with the values "+", "-", "="
         */
        public static class Operation extends EnumeratedAttribute {

            // Property type operations
            /** + */
            public static final int INCREMENT_OPER = 0;
            /** - */
            public static final int DECREMENT_OPER = 1;
            /** = */
            public static final int EQUALS_OPER =    2;
            /** del */
            public static final int DELETE_OPER =    3;

            /** {@inheritDoc}. */
            @Override
            public String[] getValues() {
                return new String[] {"+", "-", "=", "del"};
            }

            /**
             * Convert string to index.
             * @param oper the string to convert.
             * @return the index.
             */
            public static int toOperation(String oper) {
                if ("+".equals(oper)) {
                    return INCREMENT_OPER;
                }
                if ("-".equals(oper)) {
                    return DECREMENT_OPER;
                }
                if ("del".equals(oper)) {
                    return DELETE_OPER;
                }
                return EQUALS_OPER;
            }
        }

        /**
         * Enumerated attribute with the values "int", "date" and "string".
         */
        public static class Type extends EnumeratedAttribute {

            // Property types
            /** int */
            public static final int INTEGER_TYPE = 0;
            /** date */
            public static final int DATE_TYPE =    1;
            /** string */
            public static final int STRING_TYPE =  2;

            /** {@inheritDoc} */
            @Override
            public String[] getValues() {
                return new String[] {"int", "date", "string"};
            }

            /**
             * Convert string to index.
             * @param type the string to convert.
             * @return the index.
             */
            public static int toType(String type) {
                if ("int".equals(type)) {
                    return INTEGER_TYPE;
                }
                if ("date".equals(type)) {
                    return DATE_TYPE;
                }
                return STRING_TYPE;
            }
        }
    }

    /**
     * Borrowed from Tstamp
     * @todo share all this time stuff across many tasks as a datetime datatype
     * @since Ant 1.5
     */
    public static class Unit extends EnumeratedAttribute {

        private static final String MILLISECOND = "millisecond";
        private static final String SECOND = "second";
        private static final String MINUTE = "minute";
        private static final String HOUR = "hour";
        private static final String DAY = "day";
        private static final String WEEK = "week";
        private static final String MONTH = "month";
        private static final String YEAR = "year";

        private static final String[] UNITS = {MILLISECOND, SECOND, MINUTE, HOUR, DAY, WEEK, MONTH,
                YEAR};

        private Map<String, Integer> calendarFields = new HashMap<>();

        /** no arg constructor */
        public Unit() {
            calendarFields.put(MILLISECOND,
                    Calendar.MILLISECOND);
            calendarFields.put(SECOND, Calendar.SECOND);
            calendarFields.put(MINUTE, Calendar.MINUTE);
            calendarFields.put(HOUR, Calendar.HOUR_OF_DAY);
            calendarFields.put(DAY, Calendar.DATE);
            calendarFields.put(WEEK, Calendar.WEEK_OF_YEAR);
            calendarFields.put(MONTH, Calendar.MONTH);
            calendarFields.put(YEAR, Calendar.YEAR);
        }

        /**
         * Convert the value to a Calendar field index.
         * @return the calendar value.
         */
        public int getCalendarField() {
            return calendarFields.get(getValue().toLowerCase());
        }

        /** {@inheritDoc}. */
        @Override
        public String[] getValues() {
            return UNITS;
        }
    }
}
