/*
 *  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.tools.ant.types;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.StringTokenizer;
import java.util.function.Predicate;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.PropertyHelper;

/**
 * Named collection of include/exclude tags.
 *
 * <p>Moved out of MatchingTask to make it a standalone object that
 * could be referenced (by scripts for example).
 *
 */
public class PatternSet extends DataType implements Cloneable {
    private List<NameEntry> includeList = new ArrayList<>();
    private List<NameEntry> excludeList = new ArrayList<>();
    private List<PatternFileNameEntry> includesFileList = new ArrayList<>();
    private List<PatternFileNameEntry> excludesFileList = new ArrayList<>();

    /**
     * inner class to hold a name on list.  "If" and "Unless" attributes
     * may be used to invalidate the entry based on the existence of a
     * property (typically set through the use of the Available task)
     * or value of an expression.
     */
    public class NameEntry {
        private String name;
        private Object ifCond;
        private Object unlessCond;

        /**
         * Sets the name pattern.
         *
         * @param name The pattern string.
         */
        public void setName(String name) {
            this.name = name;
        }

        /**
         * Sets the if attribute. This attribute and the "unless"
         * attribute are used to validate the name, based on the
         * existence of the property or the value of the evaluated
         * property expression.
         *
         * @param cond A property name or expression.  If the
         *             expression evaluates to false or no property of
         *             its value is present, the name is invalid.
         * @since Ant 1.8.0
         */
        public void setIf(Object cond) {
            ifCond = cond;
        }

        /**
         * Sets the if attribute. This attribute and the "unless"
         * attribute are used to validate the name, based on the
         * existence of the property or the value of the evaluated
         * property expression.
         *
         * @param cond A property name or expression.  If the
         *             expression evaluates to false or no property of
         *             its value is present, the name is invalid.
         */
        public void setIf(String cond) {
            setIf((Object) cond);
        }

        /**
         * Sets the unless attribute. This attribute and the "if"
         * attribute are used to validate the name, based on the
         * existence of the property or the value of the evaluated
         * property expression.
         *
         * @param cond A property name or expression.  If the
         *             expression evaluates to true or a property of
         *             its value is present, the name is invalid.
         * @since Ant 1.8.0
         */
        public void setUnless(Object cond) {
            unlessCond = cond;
        }

        /**
         * Sets the unless attribute. This attribute and the "if"
         * attribute are used to validate the name, based on the
         * existence of the property or the value of the evaluated
         * property expression.
         *
         * @param cond A property name or expression.  If the
         *             expression evaluates to true or a property of
         *             its value is present, the name is invalid.
         */
        public void setUnless(String cond) {
            setUnless((Object) cond);
        }

        /**
         * @return the name attribute.
         */
        public String getName() {
            return name;
        }

        /**
         * This validates the name - checks the if and unless
         * properties.
         *
         * @param p the current project, used to check the presence or
         *          absence of a property.
         * @return  the name attribute or null if the "if" or "unless"
         *          properties are not/are set.
         */
        public String evalName(Project p) {
            return valid(p) ? name : null;
        }

        private boolean valid(Project p) {
            PropertyHelper ph = PropertyHelper.getPropertyHelper(p);
            return ph.testIfCondition(ifCond)
                && ph.testUnlessCondition(unlessCond);
        }

        /**
         * @return a printable form of this object.
         */
        @Override
        public String toString() {
            StringBuilder buf = new StringBuilder();
            if (name == null) {
                buf.append("noname");
            } else {
                buf.append(name);
            }
            if (ifCond != null || unlessCond != null) {
                buf.append(":");
                String connector = "";

                if (ifCond != null) {
                    buf.append("if->");
                    buf.append(ifCond);
                    connector = ";";
                }
                if (unlessCond != null) {
                    buf.append(connector);
                    buf.append("unless->");
                    buf.append(unlessCond);
                }
            }
            return buf.toString();
        }
    }

    /**
     * Adds encoding support to {@link NameEntry}.
     * @since Ant 1.10.4
     */
    public class PatternFileNameEntry extends NameEntry {
        private String encoding;

        /**
         * Encoding to use when reading the file, defaults to the platform's default
         * encoding.
         *
         * <p>
         * For a list of possible values see
         * <a href="https://docs.oracle.com/javase/1.5.0/docs/guide/intl/encoding.doc.html">
         * https://docs.oracle.com/javase/1.5.0/docs/guide/intl/encoding.doc.html</a>.
         * </p>
         *
         * @param encoding String
         */
        public final void setEncoding(String encoding) {
            this.encoding = encoding;
        }

        /**
         * Encoding to use when reading the file, defaults to the platform's default
         * encoding.
         *
         * @return the encoding name
         */
        public final String getEncoding() {
            return encoding;
        }

        @Override
        public String toString() {
            String baseString = super.toString();
            return encoding == null ? baseString
                : baseString + ";encoding->" + encoding;
        }
    }

    private static final class InvertedPatternSet extends PatternSet {
        private InvertedPatternSet(PatternSet p) {
            setProject(p.getProject());
            addConfiguredPatternset(p);
        }
        @Override
        public String[] getIncludePatterns(Project p) {
            return super.getExcludePatterns(p);
        }
        @Override
        public String[] getExcludePatterns(Project p) {
            return super.getIncludePatterns(p);
        }
    }

    /**
     * Creates a new <code>PatternSet</code> instance.
     */
    public PatternSet() {
        super();
    }

    /**
     * Makes this instance in effect a reference to another PatternSet
     * instance.
     *
     * <p>You must not set another attribute or nest elements inside
     * this element if you make it a reference.</p>
     * @param r the reference to another patternset.
     * @throws BuildException on error.
     */
    @Override
    public void setRefid(Reference r) throws BuildException {
        if (!includeList.isEmpty() || !excludeList.isEmpty()) {
            throw tooManyAttributes();
        }
        super.setRefid(r);
    }

    /**
     * This is a patternset nested element.
     *
     * @param p a configured patternset nested element.
     */
    public void addConfiguredPatternset(PatternSet p) {
        if (isReference()) {
            throw noChildrenAllowed();
        }
        String[] nestedIncludes = p.getIncludePatterns(getProject());
        String[] nestedExcludes = p.getExcludePatterns(getProject());

        if (nestedIncludes != null) {
            for (String nestedInclude : nestedIncludes) {
                createInclude().setName(nestedInclude);
            }
        }
        if (nestedExcludes != null) {
            for (String nestedExclude : nestedExcludes) {
                createExclude().setName(nestedExclude);
            }
        }
    }

    /**
     * add a name entry on the include list
     * @return a nested include element to be configured.
     */
    public NameEntry createInclude() {
        if (isReference()) {
            throw noChildrenAllowed();
        }
        return addPatternToList(includeList);
    }

    /**
     * add a name entry on the include files list
     * @return a nested includesfile element to be configured.
     */
    public NameEntry createIncludesFile() {
        if (isReference()) {
            throw noChildrenAllowed();
        }
        return addPatternFileToList(includesFileList);
    }

    /**
     * add a name entry on the exclude list
     * @return a nested exclude element to be configured.
     */
    public NameEntry createExclude() {
        if (isReference()) {
            throw noChildrenAllowed();
        }
        return addPatternToList(excludeList);
    }

    /**
     * add a name entry on the exclude files list
     * @return a nested excludesfile element to be configured.
     */
    public NameEntry createExcludesFile() {
        if (isReference()) {
            throw noChildrenAllowed();
        }
        return addPatternFileToList(excludesFileList);
    }

    /**
     * Appends <code>includes</code> to the current list of include patterns.
     * Patterns may be separated by a comma or a space.
     *
     * @param includes the string containing the include patterns
     */
    public void setIncludes(String includes) {
        if (isReference()) {
            throw tooManyAttributes();
        }
        if (includes != null && !includes.isEmpty()) {
            StringTokenizer tok = new StringTokenizer(includes, ", ", false);
            while (tok.hasMoreTokens()) {
                createInclude().setName(tok.nextToken());
            }
        }
    }

    /**
     * Appends <code>excludes</code> to the current list of exclude patterns.
     * Patterns may be separated by a comma or a space.
     *
     * @param excludes the string containing the exclude patterns
     */
    public void setExcludes(String excludes) {
        if (isReference()) {
            throw tooManyAttributes();
        }
        if (excludes != null && !excludes.isEmpty()) {
            StringTokenizer tok = new StringTokenizer(excludes, ", ", false);
            while (tok.hasMoreTokens()) {
                createExclude().setName(tok.nextToken());
            }
        }
    }

    /**
     * add a name entry to the given list
     */
    private NameEntry addPatternToList(List<NameEntry> list) {
        NameEntry result = new NameEntry();
        list.add(result);
        return result;
    }

    /**
     * add a pattern file name entry to the given list
     */
    private PatternFileNameEntry addPatternFileToList(List<PatternFileNameEntry> list) {
        PatternFileNameEntry result = new PatternFileNameEntry();
        list.add(result);
        return result;
    }

    /**
     * Sets the name of the file containing the includes patterns.
     *
     * @param includesFile The file to fetch the include patterns from.
     * @throws BuildException on error.
     */
     public void setIncludesfile(File includesFile) throws BuildException {
         if (isReference()) {
             throw tooManyAttributes();
         }
         createIncludesFile().setName(includesFile.getAbsolutePath());
     }

    /**
     * Sets the name of the file containing the excludes patterns.
     *
     * @param excludesFile The file to fetch the exclude patterns from.
     * @throws BuildException on error.
     */
     public void setExcludesfile(File excludesFile) throws BuildException {
         if (isReference()) {
             throw tooManyAttributes();
         }
         createExcludesFile().setName(excludesFile.getAbsolutePath());
     }

    /**
     *  Reads path matching patterns from a file and adds them to the
     *  includes or excludes list (as appropriate).
     */
    private void readPatterns(File patternfile, String encoding, List<NameEntry> patternlist, Project p)
            throws BuildException {

        try (Reader r = encoding == null ? new FileReader(patternfile)
             : new InputStreamReader(new FileInputStream(patternfile), encoding);
             BufferedReader patternReader = new BufferedReader(r)) {

            // Create one NameEntry in the appropriate pattern list for each
            // line in the file.
            patternReader.lines()
                .filter(((Predicate<String>) String::isEmpty).negate())
                .map(p::replaceProperties)
                .forEach(line -> addPatternToList(patternlist).setName(line));

        } catch (IOException ioe)  {
            throw new BuildException("An error occurred while reading from pattern file: "
                    + patternfile, ioe);
        }
    }

    /**
     * Adds the patterns of the other instance to this set.
     * @param other the other PatternSet instance.
     * @param p the current project.
     */
    public void append(PatternSet other, Project p) {
        if (isReference()) {
            throw new BuildException("Cannot append to a reference");
        }
        dieOnCircularReference(p);
        String[] incl = other.getIncludePatterns(p);
        if (incl != null) {
            for (String include : incl) {
                createInclude().setName(include);
            }
        }
        String[] excl = other.getExcludePatterns(p);
        if (excl != null) {
            for (String exclude : excl) {
                createExclude().setName(exclude);
            }
        }
    }

    /**
     * Returns the filtered include patterns.
     * @param p the current project.
     * @return the filtered included patterns.
     */
    public String[] getIncludePatterns(Project p) {
        if (isReference()) {
            return getRef(p).getIncludePatterns(p);
        }
        dieOnCircularReference(p);
        readFiles(p);
        return makeArray(includeList, p);
    }

    /**
     * Returns the filtered include patterns.
     * @param p the current project.
     * @return the filtered excluded patterns.
     */
    public String[] getExcludePatterns(Project p) {
        if (isReference()) {
            return getRef(p).getExcludePatterns(p);
        }
        dieOnCircularReference(p);
        readFiles(p);
        return makeArray(excludeList, p);
    }

    /**
     * Helper for FileSet classes.
     * Check if there are patterns defined.
     * @param p the current project.
     * @return true if there are patterns.
     */
    public boolean hasPatterns(Project p) {
        if (isReference()) {
            return getRef(p).hasPatterns(p);
        }
        dieOnCircularReference(p);
        return !(includesFileList.isEmpty() && excludesFileList.isEmpty()
            && includeList.isEmpty() && excludeList.isEmpty());
    }

    /**
     * Performs the check for circular references and returns the
     * referenced PatternSet.
     */
    private PatternSet getRef(Project p) {
        return getCheckedRef(PatternSet.class, getDataTypeName(), p);
    }

    /**
     * Convert a vector of NameEntry elements into an array of Strings.
     */
    private String[] makeArray(List<NameEntry> list, Project p) {
        if (list.isEmpty()) {
            return null;
        }
        return list.stream().map(ne -> ne.evalName(p)).filter(Objects::nonNull)
            .filter(pattern -> !pattern.isEmpty()).toArray(String[]::new);
    }

    /**
     * Read includesfile ot excludesfile if not already done so.
     */
    private void readFiles(Project p) {
        if (!includesFileList.isEmpty()) {
            for (PatternFileNameEntry ne : includesFileList) {
                String fileName = ne.evalName(p);
                if (fileName != null) {
                    File inclFile = p.resolveFile(fileName);
                    if (!inclFile.exists()) {
                        throw new BuildException("Includesfile " + inclFile.getAbsolutePath()
                                + " not found.");
                    }
                    readPatterns(inclFile, ne.getEncoding(), includeList, p);
                }
            }
            includesFileList.clear();
        }
        if (!excludesFileList.isEmpty()) {
            for (PatternFileNameEntry ne : excludesFileList) {
                String fileName = ne.evalName(p);
                if (fileName != null) {
                    File exclFile = p.resolveFile(fileName);
                    if (!exclFile.exists()) {
                        throw new BuildException("Excludesfile " + exclFile.getAbsolutePath()
                                + " not found.");
                    }
                    readPatterns(exclFile, ne.getEncoding(), excludeList, p);
                }
            }
            excludesFileList.clear();
        }
    }

    /**
     * @return a printable form of this object.
     */
    @Override
    public String toString() {
        return String.format("patternSet{ includes: %s excludes: %s }",
            includeList, excludeList);
    }

    /**
     * @since Ant 1.6
     * @return a clone of this patternset.
     */
    @Override
    public Object clone() {
        try {
            PatternSet ps = (PatternSet) super.clone();
            ps.includeList = new ArrayList<>(includeList);
            ps.excludeList = new ArrayList<>(excludeList);
            ps.includesFileList = new ArrayList<>(includesFileList);
            ps.excludesFileList = new ArrayList<>(excludesFileList);
            return ps;
        } catch (CloneNotSupportedException e) {
            throw new BuildException(e);
        }
    }

    /**
     * Add an inverted patternset.
     * @param p the pattern to invert and add.
     */
    public void addConfiguredInvert(PatternSet p) {
        addConfiguredPatternset(new InvertedPatternSet(p));
    }
}
