/*
 * 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.rat.document;

import java.io.File;
import java.io.FileFilter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;

import org.apache.rat.ConfigurationException;
import org.apache.rat.config.exclusion.plexus.MatchPattern;
import org.apache.rat.config.exclusion.plexus.MatchPatterns;
import org.apache.rat.utils.DefaultLog;
import org.apache.rat.utils.Log;

import static java.lang.String.format;

/**
 * Matches document names.
 */
public final class DocumentNameMatcher {

    /** The predicate that does the actual matching. */
    private final Predicate<DocumentName> predicate;
    /** The name of this matcher. */
    private final String name;
    /** {@code true} if this matcher is a collection of matchers. */
    private final boolean isCollection;

    /**
     * A matcher that matches all documents.
     */
    public static final DocumentNameMatcher MATCHES_ALL = new DocumentNameMatcher("TRUE", (Predicate<DocumentName>) x -> true);

    /**
     * A matcher that matches no documents.
     */
    public static final DocumentNameMatcher MATCHES_NONE = new DocumentNameMatcher("FALSE", (Predicate<DocumentName>) x -> false);

    /**
     * Constructs a DocumentNameMatcher from a name and a DocumentName predicate.
     * @param name the name for this matcher.
     * @param predicate the predicate to determine matches.
     */
    public DocumentNameMatcher(final String name, final Predicate<DocumentName> predicate) {
        this.name = name;
        this.predicate = predicate;
        this.isCollection = predicate instanceof CompoundPredicate;
    }

    /**
     * Constructs a DocumentNameMatcher from a name and a delegate DocumentNameMatcher.
     * @param name the name for this matcher.
     * @param delegate the delegate to defer to.
     */
    public DocumentNameMatcher(final String name, final DocumentNameMatcher delegate) {
        this(name, delegate::matches);
    }

    /**
     * Constructs a DocumentNameMatcher from a name a MatchPatterns instance and the DocumentName for the base name.
     * @param name the name of this matcher.
     * @param patterns the patterns in the matcher.
     * @param basedir the base directory for the scanning.
     */
    public DocumentNameMatcher(final String name, final MatchPatterns patterns, final DocumentName basedir) {
        this(name, new MatchPatternsPredicate(basedir, patterns));
    }

    /**
     * Tokenizes name for faster Matcher processing.
     * @param name the name to tokenize.
     * @param dirSeparator the directory separator.
     * @return the tokenized name.
     */
    private static char[][] tokenize(final String name, final String dirSeparator) {
        String[] tokenizedName = MatchPattern.tokenizePathToString(name, dirSeparator);
        char[][] tokenizedNameChar = new char[tokenizedName.length][];
        for (int i = 0; i < tokenizedName.length; i++) {
            tokenizedNameChar[i] = tokenizedName[i].toCharArray();
        }
        return tokenizedNameChar;
    }

    /**
     * Constructs a DocumentNameMatcher from a name and a MatcherPatterns object.
     * @param name the name of the matcher.
     * @param matchers fully specified matchers.
     */
    public DocumentNameMatcher(final String name, final MatchPatterns matchers) {
        this(name, new CompoundPredicate() {
            @Override
            public Iterable<DocumentNameMatcher> getMatchers() {
                final List<DocumentNameMatcher> result = new ArrayList<>();
                matchers.patterns().forEach(p -> result.add(new DocumentNameMatcher(p.source(),
                        new Predicate<DocumentName>() {
                            private final MatchPatterns patterns = MatchPatterns.from("/", p.source());

                            @Override
                            public boolean test(final DocumentName documentName) {
                                return patterns.matches(documentName.getName(), documentName.isCaseSensitive());
                            }
                        })));
                return result;
            }

            @Override
            public boolean test(final DocumentName documentName) {
                return matchers.matches(documentName.getName(), documentName.isCaseSensitive());
            }
        });
    }

    /**
     * Creates a DocumentNameMatcher from a File filter.
     * @param name The name of this matcher.
     * @param fileFilter the file filter to execute.
     */
    public DocumentNameMatcher(final String name, final FileFilter fileFilter) {
        this(name, new FileFilterPredicate(fileFilter));
    }

    /**
     * Creates a DocumentNameMatcher from a File filter.
     * @param fileFilter the file filter to execute.
     */
    public DocumentNameMatcher(final FileFilter fileFilter) {
        this(fileFilter.toString(), fileFilter);
    }

    public boolean isCollection() {
        return isCollection;
    }

    /**
     * Returns the predicate that this DocumentNameMatcher is using.
     * @return The predicate that this DocumentNameMatcher is using.
     */
    public Predicate<DocumentName> getPredicate() {
        return predicate;
    }

    @Override
    public String toString() {
        return name;
    }

    /**
     * Calculates the match result and logs the calculations.
     * @param candidate the candidate to evaluate the match for.
     * @return the result of the calculation
     */
    public boolean logDecompositionWhileMatching(final DocumentName candidate) {
        boolean result = matches(candidate);
        Log log = DefaultLog.getInstance();
        Log.Level level = log.getLevel();
        log.log(level, format("FILTER TEST for %s -> %s", candidate, result));
        List<DecomposeData> data = decompose(candidate);
        log.log(level, "Decomposition for " + candidate);
        data.forEach(s -> log.log(level, s));
        return result;
    }

    /**
     * Decomposes the matcher execution against the candidate.
     * @param candidate the candidate to check.
     * @return a list of {@link DecomposeData} for each evaluation in the matcher.
     */
    public List<DecomposeData> decompose(final DocumentName candidate) {
        final List<DecomposeData> result = new ArrayList<>();
        decompose(0, this, candidate, result);
        return result;
    }

    private void decompose(final int level, final DocumentNameMatcher matcher, final DocumentName candidate, final List<DecomposeData> result) {
        final Predicate<DocumentName> pred = matcher.getPredicate();
        result.add(new DecomposeData(level, matcher, candidate, pred.test(candidate)));
    }

    /**
     * Performs the match against the DocumentName.
     * @param documentName the document name to check.
     * @return true if the documentName matchers this DocumentNameMatcher.
     */
    public boolean matches(final DocumentName documentName) {
        return predicate.test(documentName);
    }

    /**
     * Performs a logical {@code NOT} on a DocumentNameMatcher.
     * @param nameMatcher the matcher to negate.
     * @return a PathMatcher that is the negation of the argument.
     */
    public static DocumentNameMatcher not(final DocumentNameMatcher nameMatcher) {
        if (nameMatcher == MATCHES_ALL) {
            return MATCHES_NONE;
        }
        if (nameMatcher == MATCHES_NONE) {
            return MATCHES_ALL;
        }

        return new DocumentNameMatcher(format("not(%s)", nameMatcher), new NotPredicate(nameMatcher));
    }

    /**
     * Joins a collection of DocumentNameMatchers together to create a list of the names.
     * @param matchers the matchers to extract the names from.
     * @return the String of the concatenation of the names.
     */
    private static String join(final Collection<DocumentNameMatcher> matchers) {
        List<String> children = new ArrayList<>();
        matchers.forEach(s -> children.add(s.toString()));
        return String.join(", ", children);
    }

    private static Optional<DocumentNameMatcher> standardCollectionCheck(final Collection<DocumentNameMatcher> matchers, final DocumentNameMatcher override) {
        if (matchers.isEmpty()) {
            throw new ConfigurationException("Empty matcher collection");
        }
        if (matchers.size() == 1) {
            return Optional.of(matchers.iterator().next());
        }
        if (matchers.contains(override)) {
            return Optional.of(override);
        }
        return Optional.empty();
    }

    /**
     * Performs a logical {@code OR} across the collection of matchers.
     * @param matchers the matchers to check.
     * @return a matcher that returns {@code true} if any of the enclosed matchers returns {@code true}.
     */
    public static DocumentNameMatcher or(final Collection<DocumentNameMatcher> matchers) {
        Optional<DocumentNameMatcher> opt = standardCollectionCheck(matchers, MATCHES_ALL);
        if (opt.isPresent()) {
            return opt.get();
        }

        // preserve order
        Set<DocumentNameMatcher> workingSet = new LinkedHashSet<>();
        for (DocumentNameMatcher matcher : matchers) {
            // check for nested or
            if (matcher.predicate instanceof Or) {
                ((Or) matcher.predicate).getMatchers().forEach(workingSet::add);
            } else {
                workingSet.add(matcher);
            }
        }
        return standardCollectionCheck(matchers, MATCHES_ALL)
                .orElseGet(() -> new DocumentNameMatcher(format("or(%s)", join(workingSet)), new Or(workingSet)));
    }

    /**
     * Performs a logical {@code OR} across the collection of matchers.
     * @param matchers the matchers to check.
     * @return a matcher that returns {@code true} if any of the enclosed matchers returns {@code true}.
     */
    public static DocumentNameMatcher or(final DocumentNameMatcher... matchers) {
        return or(Arrays.asList(matchers));
    }

    /**
     * Performs a logical {@code AND} across the collection of matchers.
     * @param matchers the matchers to check.
     * @return a matcher that returns {@code true} if all the enclosed matchers return {@code true}.
     */
    public static DocumentNameMatcher and(final Collection<DocumentNameMatcher> matchers) {
        Optional<DocumentNameMatcher> opt = standardCollectionCheck(matchers, MATCHES_NONE);
        if (opt.isPresent()) {
            return opt.get();
        }

        // preserve order
        Set<DocumentNameMatcher> workingSet = new LinkedHashSet<>();
        for (DocumentNameMatcher matcher : matchers) {
            //  check for nexted And
            if (matcher.predicate instanceof And) {
                ((And) matcher.predicate).getMatchers().forEach(workingSet::add);
            } else {
                workingSet.add(matcher);
            }
        }
        opt = standardCollectionCheck(matchers, MATCHES_NONE);
        return opt.orElseGet(() -> new DocumentNameMatcher(format("and(%s)", join(workingSet)), new And(workingSet)));
    }

    /**
     * A particular matcher that will not match any excluded unless they are listed in the includes.
     * @param includes the DocumentNameMatcher to match the includes.
     * @param excludes the DocumentNameMatcher to match the excludes.
     * @return a DocumentNameMatcher with the specified logic.
     */
    public static DocumentNameMatcher matcherSet(final DocumentNameMatcher includes,
                                                 final DocumentNameMatcher excludes) {
        if (excludes == MATCHES_NONE) {
            return MATCHES_ALL;
        } else {
            if (includes == MATCHES_NONE) {
                return not(excludes);
            }
        }
        if (includes == MATCHES_ALL) {
            return MATCHES_ALL;
        }
        List<DocumentNameMatcher> workingSet = Arrays.asList(includes, excludes);
        return new DocumentNameMatcher(format("matcherSet(%s)", join(workingSet)),
                new DefaultCompoundPredicate(workingSet) {
                    @Override
                    public boolean test(final DocumentName documentName) {
                        if (includes.matches(documentName)) {
                            return true;
                        }
                        return !excludes.matches(documentName);
                    }
                });
    }

    /**
     * Performs a logical {@code AND} across the collection of matchers.
     * @param matchers the matchers to check.
     * @return a matcher that returns {@code true} if all the enclosed matchers return {@code true}.
     */
    public static DocumentNameMatcher and(final DocumentNameMatcher... matchers) {
        return and(Arrays.asList(matchers));
    }

    /**
     * A DocumentName predicate that uses {@link MatchPatterns}.
     */
    public static final class MatchPatternsPredicate implements Predicate<DocumentName> {
        /** The base directory for the pattern matches. */
        private final DocumentName basedir;
        /** The pattern matchers. */
        private final MatchPatterns patterns;

        private MatchPatternsPredicate(final DocumentName basedir, final MatchPatterns patterns) {
            this.basedir = basedir;
            this.patterns = patterns;
        }

        @Override
        public boolean test(final DocumentName documentName) {
            return patterns.matches(documentName.getName(),
                    tokenize(documentName.getName(), basedir.getDirectorySeparator()),
                    basedir.isCaseSensitive());
        }

        @Override
        public String toString() {
            return patterns.toString();
        }
    }

    /**
     * A DocumentName predicate that reverses another DocumentNameMatcher.
     */
    public static final class NotPredicate implements Predicate<DocumentName> {
        /** The document name matcher to reverse */
        private final DocumentNameMatcher nameMatcher;

        private NotPredicate(final DocumentNameMatcher nameMatcher) {
            this.nameMatcher = nameMatcher;
        }

        @Override
        public boolean test(final DocumentName documentName) {
            return !nameMatcher.matches(documentName);
        }

        @Override
        public String toString() {
            return nameMatcher.predicate.toString();
        }
    }

    /**
     * A DocumentName predicate that uses {@link FileFilter}.
     */
    public static final class FileFilterPredicate implements Predicate<DocumentName> {
        /** The file filter. */
        private final FileFilter fileFilter;

        private FileFilterPredicate(final FileFilter fileFilter) {
            this.fileFilter = fileFilter;
        }

        @Override
        public boolean test(final DocumentName documentName) {
            return fileFilter.accept(new File(documentName.getName()));
        }

        @Override
        public String toString() {
            return fileFilter.toString();
        }
    }

    /**
     * A marker interface to indicate this predicate contains a collection of matchers.
     */
    interface CompoundPredicate extends Predicate<DocumentName> {
        Iterable<DocumentNameMatcher> getMatchers();
    }
    /**
     * A {@link CompoundPredicate} implementation.
     */
    abstract static class DefaultCompoundPredicate implements CompoundPredicate {
        /** The collection for matchers that make up this predicate */
        private final Iterable<DocumentNameMatcher> matchers;

        /**
         * Constructs a collection predicate from the collection of matchers.
         * @param matchers the collection of matchers to use.
         */
        protected DefaultCompoundPredicate(final Iterable<DocumentNameMatcher> matchers) {
            this.matchers = matchers;
        }

        /**
         * Gets the internal matchers.
         * @return an iterable over the internal matchers.
         */
        public Iterable<DocumentNameMatcher> getMatchers() {
            return matchers;
        }

        @Override
        public String toString() {
            StringBuilder builder = new StringBuilder(this.getClass().getName()).append(": ").append(System.lineSeparator());
            for (DocumentNameMatcher matcher : matchers) {
                builder.append(matcher.predicate.toString()).append(System.lineSeparator());
            }
            return builder.toString();
        }
    }

    /**
     * An implementation of "and" logic across a collection of DocumentNameMatchers.
     */
    // package private for testing access
    static class And extends DefaultCompoundPredicate {
        And(final Iterable<DocumentNameMatcher> matchers) {
            super(matchers);
        }

        @Override
        public boolean test(final DocumentName documentName) {
            for (DocumentNameMatcher matcher : getMatchers()) {
                if (!matcher.matches(documentName)) {
                    return false;
                }
            }
            return true;
        }
    }

    /**
     * An implementation of "or" logic across a collection of DocumentNameMatchers.
     */
    // package private for testing access
    static class Or extends DefaultCompoundPredicate {
        Or(final Iterable<DocumentNameMatcher> matchers) {
            super(matchers);
        }

        @Override
        public boolean test(final DocumentName documentName) {
            for (DocumentNameMatcher matcher : getMatchers()) {
                if (matcher.matches(documentName)) {
                    return true;
                }
            }
            return false;
        }
    }

    /**
     * Data from a {@link DocumentNameMatcher#decompose(DocumentName)} call.
     */
    public static final class DecomposeData {
        /** The level this data was generated at. */
        private final int level;
        /** The name of the DocumentNameMatcher that created this result. */
        private final DocumentNameMatcher matcher;
        /** The result of the check. */
        private final boolean result;
        /** The candidate */
        private final DocumentName candidate;

        private DecomposeData(final int level, final DocumentNameMatcher matcher, final DocumentName candidate, final boolean result) {
            this.level = level;
            this.matcher = matcher;
            this.result = result;
            this.candidate = candidate;
        }

        @Override
        public String toString() {
            final String fill = createFill(level);
            return format("%s%s: >>%s<< %s%n%s",
                    fill, matcher.toString(), result,
                    level == 0 ? candidate.getName() : "",
                    matcher.predicate instanceof CompoundPredicate ?
                            decompose(level + 1, (CompoundPredicate) matcher.predicate, candidate) :
                    String.format("%s%s >>%s<<", createFill(level + 1), matcher.predicate.toString(), matcher.predicate.test(candidate)));
        }

        private String createFill(final int level) {
            final char[] chars = new char[level * 2];
            Arrays.fill(chars, ' ');
            return new String(chars);
        }

        private String decompose(final int level, final CompoundPredicate predicate, final DocumentName candidate) {
            List<DecomposeData> result = new ArrayList<>();

            for (DocumentNameMatcher nameMatcher : predicate.getMatchers()) {
                nameMatcher.decompose(level, nameMatcher, candidate, result);
            }
            StringBuilder sb = new StringBuilder();
            result.forEach(x -> sb.append(x).append(System.lineSeparator()));
            return sb.toString();
        }
    }
}
