/*
 * 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.maven.shared.artifact.filter;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import org.apache.maven.artifact.Artifact;
import org.apache.maven.artifact.resolver.filter.ArtifactFilter;
import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
import org.apache.maven.artifact.versioning.InvalidVersionSpecificationException;
import org.apache.maven.artifact.versioning.VersionRange;
import org.slf4j.Logger;

import static java.util.Objects.requireNonNull;

/**
 * TODO: include in maven-artifact in future
 *
 * @author <a href="mailto:brett@apache.org">Brett Porter</a>
 * @see StrictPatternIncludesArtifactFilter
 */
public class PatternIncludesArtifactFilter implements ArtifactFilter, StatisticsReportingArtifactFilter {
    private static final String SEP = System.lineSeparator();

    /**
     * Holds the set of compiled patterns
     */
    private final Set<Pattern> patterns;

    /**
     * Whether the dependency trail should be checked
     */
    private final boolean actTransitively;

    /**
     * Set of patterns that have been triggered
     */
    private final Set<Pattern> patternsTriggered = new HashSet<>();

    /**
     * Set of artifacts that have been filtered out
     */
    private final List<Artifact> filteredArtifact = new ArrayList<>();

    /**
     * <p>Constructor for PatternIncludesArtifactFilter.</p>
     *
     * @param patterns The pattern to be used.
     */
    public PatternIncludesArtifactFilter(final Collection<String> patterns) {
        this(patterns, false);
    }

    /**
     * <p>Constructor for PatternIncludesArtifactFilter.</p>
     *
     * @param patterns        The pattern to be used.
     * @param actTransitively transitive yes/no.
     */
    public PatternIncludesArtifactFilter(final Collection<String> patterns, final boolean actTransitively) {
        this.actTransitively = actTransitively;
        final Set<Pattern> pat = new LinkedHashSet<>();
        if (patterns != null && !patterns.isEmpty()) {
            for (String pattern : patterns) {
                Pattern p = compile(pattern);
                pat.add(p);
            }
        }
        this.patterns = pat;
    }

    @Override
    public boolean include(final Artifact artifact) {
        final boolean shouldInclude = patternMatches(artifact);

        if (!shouldInclude) {
            addFilteredArtifact(artifact);
        }

        return shouldInclude;
    }

    protected boolean patternMatches(final Artifact artifact) {
        Boolean match = match(adapt(artifact));
        if (match != null) {
            return match;
        }

        if (actTransitively) {
            final List<String> depTrail = artifact.getDependencyTrail();

            if (depTrail != null && depTrail.size() > 1) {
                for (String trailItem : depTrail) {
                    Artifactoid artifactoid = adapt(trailItem);
                    match = match(artifactoid);
                    if (match != null) {
                        return match;
                    }
                }
            }
        }

        return false;
    }

    private Boolean match(Artifactoid artifactoid) {
        for (Pattern pattern : patterns) {
            if (pattern.matches(artifactoid)) {
                patternsTriggered.add(pattern);
                return !(pattern instanceof NegativePattern);
            }
        }

        return null;
    }

    /**
     * <p>addFilteredArtifact.</p>
     *
     * @param artifact add artifact to the filtered artifacts list.
     */
    protected void addFilteredArtifact(final Artifact artifact) {
        filteredArtifact.add(artifact);
    }

    @Override
    public void reportMissedCriteria(final Logger logger) {
        // if there are no patterns, there is nothing to report.
        if (!patterns.isEmpty()) {
            final List<Pattern> missed = new ArrayList<>(patterns);
            missed.removeAll(patternsTriggered);

            if (!missed.isEmpty() && logger.isWarnEnabled()) {
                final StringBuilder buffer = new StringBuilder();

                buffer.append("The following patterns were never triggered in this ");
                buffer.append(getFilterDescription());
                buffer.append(':');

                for (Pattern pattern : missed) {
                    buffer.append(SEP).append("o  '").append(pattern).append("'");
                }

                buffer.append(SEP);

                logger.warn(buffer.toString());
            }
        }
    }

    @Override
    public String toString() {
        return "Includes filter:" + getPatternsAsString();
    }

    protected String getPatternsAsString() {
        final StringBuilder buffer = new StringBuilder();
        for (Pattern pattern : patterns) {
            buffer.append(SEP).append("o '").append(pattern).append("'");
        }

        return buffer.toString();
    }

    protected String getFilterDescription() {
        return "artifact inclusion filter";
    }

    @Override
    public void reportFilteredArtifacts(final Logger logger) {
        if (!filteredArtifact.isEmpty() && logger.isDebugEnabled()) {
            final StringBuilder buffer =
                    new StringBuilder("The following artifacts were removed by this " + getFilterDescription() + ": ");

            for (Artifact artifactId : filteredArtifact) {
                buffer.append(SEP).append(artifactId.getId());
            }

            logger.debug(buffer.toString());
        }
    }

    @Override
    public boolean hasMissedCriteria() {
        // if there are no patterns, there is nothing to report.
        if (!patterns.isEmpty()) {
            final List<Pattern> missed = new ArrayList<>(patterns);
            missed.removeAll(patternsTriggered);
            return !missed.isEmpty();
        }

        return false;
    }

    private enum Coordinate {
        GROUP_ID,
        ARTIFACT_ID,
        TYPE,
        CLASSIFIER,
        BASE_VERSION
    }

    private interface Artifactoid {
        String getCoordinate(Coordinate coordinate);
    }

    private static Artifactoid adapt(final Artifact artifact) {
        requireNonNull(artifact);
        return coordinate -> {
            requireNonNull(coordinate);
            switch (coordinate) {
                case GROUP_ID:
                    return artifact.getGroupId();
                case ARTIFACT_ID:
                    return artifact.getArtifactId();
                case BASE_VERSION:
                    return artifact.getBaseVersion();
                case CLASSIFIER:
                    return artifact.hasClassifier() ? artifact.getClassifier() : null;
                case TYPE:
                    return artifact.getType();
                default:
            }
            throw new IllegalArgumentException("unknown coordinate: " + coordinate);
        };
    }

    /**
     * Parses elements of {@link Artifact#getDependencyTrail()} list, they are either {@code G:A:T:V} or if artifact
     * has classifier {@code G:A:T:C:V}, so strictly 4 or 5 segments only.
     */
    private static Artifactoid adapt(final String depTrailString) {
        requireNonNull(depTrailString);
        String[] coordinates = depTrailString.split(":");
        if (coordinates.length != 4 && coordinates.length != 5) {
            throw new IllegalArgumentException("Bad dep trail string: " + depTrailString);
        }
        final HashMap<Coordinate, String> map = new HashMap<>();
        map.put(Coordinate.GROUP_ID, coordinates[0]);
        map.put(Coordinate.ARTIFACT_ID, coordinates[1]);
        map.put(Coordinate.TYPE, coordinates[2]);
        if (coordinates.length == 5) {
            map.put(Coordinate.CLASSIFIER, coordinates[3]);
            map.put(Coordinate.BASE_VERSION, coordinates[4]);
        } else {
            map.put(Coordinate.BASE_VERSION, coordinates[3]);
        }

        return coordinate -> {
            requireNonNull(coordinate);
            return map.get(coordinate);
        };
    }

    private static final String ANY = "*";

    /**
     * Splits the pattern string into tokens, replacing empty tokens with {@link #ANY} for patterns like {@code ::val}
     * so it retains the position of token.
     */
    private static String[] splitAndTokenize(String pattern) {
        String[] stokens = pattern.split(":");
        String[] tokens = new String[stokens.length];
        for (int i = 0; i < stokens.length; i++) {
            String str = stokens[i];
            tokens[i] = str != null && !str.isEmpty() ? str : ANY;
        }
        return tokens;
    }

    /**
     * Compiles pattern string into {@link Pattern}.
     *
     * TODO: patterns seems NOT documented anywhere, so best we have is source below.
     * TODO: patterns in some cases (3, 2 tokens) seems ambiguous, we may need to clean up the specs
     */
    private static Pattern compile(String pattern) {
        if (pattern.startsWith("!")) {
            return new NegativePattern(pattern, compile(pattern.substring(1)));
        } else {
            String[] tokens = splitAndTokenize(pattern);
            if (tokens.length < 1 || tokens.length > 5) {
                throw new IllegalArgumentException("Invalid pattern: " + pattern);
            }

            ArrayList<Pattern> patterns = new ArrayList<>(5);

            if (tokens.length == 5) {
                // trivial, full pattern w/ classifier: G:A:T:C:V
                patterns.add(toPattern(tokens[0], Coordinate.GROUP_ID));
                patterns.add(toPattern(tokens[1], Coordinate.ARTIFACT_ID));
                patterns.add(toPattern(tokens[2], Coordinate.TYPE));
                patterns.add(toPattern(tokens[3], Coordinate.CLASSIFIER));
                patterns.add(toPattern(tokens[4], Coordinate.BASE_VERSION));
            } else if (tokens.length == 4) {
                // trivial, full pattern w/ version or classifier: G:A:T:V or G:A:T:C
                patterns.add(toPattern(tokens[0], Coordinate.GROUP_ID));
                patterns.add(toPattern(tokens[1], Coordinate.ARTIFACT_ID));
                patterns.add(toPattern(tokens[2], Coordinate.TYPE));
                patterns.add(toPattern(tokens[3], Coordinate.BASE_VERSION, Coordinate.CLASSIFIER));
            } else if (tokens.length == 3) {
                // tricky: may be "*:artifact:*" but also "*:war:*"

                // *:*:* -> ALL
                // *:*:xxx -> TC(xxx)
                // *:xxx:* -> AT(xxx)
                // *:xxx:yyy -> GA(xxx) + TC(XXX)
                // xxx:*:* -> GA(xxx)
                // xxx:*:yyy -> G(xxx) + TC(yyy)
                // xxx:yyy:* -> G(xxx)+A(yyy)
                // xxx:yyy:zzz -> G(xxx)+A(yyy)+T(zzz)
                if (ANY.equals(tokens[0]) && ANY.equals(tokens[1]) && ANY.equals(tokens[2])) {
                    patterns.add(MATCH_ALL_PATTERN);
                } else if (ANY.equals(tokens[0]) && ANY.equals(tokens[1])) {
                    patterns.add(toPattern(pattern, tokens[2], Coordinate.TYPE, Coordinate.CLASSIFIER));
                } else if (ANY.equals(tokens[0]) && ANY.equals(tokens[2])) {
                    patterns.add(toPattern(pattern, tokens[1], Coordinate.ARTIFACT_ID, Coordinate.TYPE));
                } else if (ANY.equals(tokens[0])) {
                    patterns.add(toPattern(pattern, tokens[1], Coordinate.GROUP_ID, Coordinate.ARTIFACT_ID));
                    patterns.add(toPattern(pattern, tokens[2], Coordinate.TYPE, Coordinate.CLASSIFIER));
                } else if (ANY.equals(tokens[1]) && ANY.equals(tokens[2])) {
                    patterns.add(toPattern(pattern, tokens[0], Coordinate.GROUP_ID, Coordinate.ARTIFACT_ID));
                } else if (ANY.equals(tokens[1])) {
                    patterns.add(toPattern(tokens[0], tokens[0], Coordinate.GROUP_ID));
                    patterns.add(toPattern(pattern, tokens[2], Coordinate.TYPE, Coordinate.CLASSIFIER));
                } else if (ANY.equals(tokens[2])) {
                    patterns.add(toPattern(tokens[0], Coordinate.GROUP_ID));
                    patterns.add(toPattern(tokens[1], Coordinate.ARTIFACT_ID));
                } else {
                    patterns.add(toPattern(tokens[0], Coordinate.GROUP_ID));
                    patterns.add(toPattern(tokens[1], Coordinate.ARTIFACT_ID));
                    patterns.add(toPattern(tokens[2], Coordinate.TYPE));
                }

            } else if (tokens.length == 2) {
                // tricky: may be "*:artifact" but also "*:war"
                // *:* -> ALL
                // *:xxx -> GATV(xxx)
                // xxx:* -> G(xxx)
                // xxx:yyy -> G(xxx)+A(yyy)

                if (ANY.equals(tokens[0]) && ANY.equals(tokens[1])) {
                    patterns.add(MATCH_ALL_PATTERN);
                } else if (ANY.equals(tokens[0])) {
                    patterns.add(toPattern(
                            pattern,
                            tokens[1],
                            Coordinate.GROUP_ID,
                            Coordinate.ARTIFACT_ID,
                            Coordinate.TYPE,
                            Coordinate.BASE_VERSION));
                } else if (ANY.equals(tokens[1])) {
                    patterns.add(toPattern(tokens[0], Coordinate.GROUP_ID));
                } else {
                    patterns.add(toPattern(tokens[0], Coordinate.GROUP_ID));
                    patterns.add(toPattern(tokens[1], Coordinate.ARTIFACT_ID));
                }
            } else {
                // trivial: G
                patterns.add(toPattern(tokens[0], Coordinate.GROUP_ID));
            }

            // build result if needed and retains pattern string
            if (patterns.size() == 1) {
                Pattern pat = patterns.get(0);
                if (pat == MATCH_ALL_PATTERN) {
                    return new MatchAllPattern(pattern);
                } else {
                    return pat;
                }
            } else {
                return new AndPattern(pattern, patterns.toArray(new Pattern[0]));
            }
        }
    }

    private static Pattern toPattern(final String token, final Coordinate... coordinates) {
        return toPattern(token, token, coordinates);
    }

    private static Pattern toPattern(final String pattern, final String token, final Coordinate... coordinates) {
        if (ANY.equals(token)) {
            return MATCH_ALL_PATTERN;
        } else {
            EnumSet<Coordinate> coordinateSet = EnumSet.noneOf(Coordinate.class);
            coordinateSet.addAll(Arrays.asList(coordinates));
            return new CoordinateMatchingPattern(pattern, token, coordinateSet);
        }
    }

    private static final Pattern MATCH_ALL_PATTERN = new MatchAllPattern(ANY);

    private abstract static class Pattern {
        protected final String pattern;

        private Pattern(String pattern) {
            this.pattern = requireNonNull(pattern);
        }

        public abstract boolean matches(Artifactoid artifact);

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

    private static class AndPattern extends Pattern {
        private final Pattern[] patterns;

        private AndPattern(String pattern, Pattern[] patterns) {
            super(pattern);
            this.patterns = patterns;
        }

        @Override
        public boolean matches(Artifactoid artifactoid) {
            for (Pattern pattern : patterns) {
                if (!pattern.matches(artifactoid)) {
                    return false;
                }
            }
            return true;
        }
    }

    private static class CoordinateMatchingPattern extends Pattern {
        private final String token;

        private final EnumSet<Coordinate> coordinates;

        private final boolean containsWildcard;

        private final boolean containsAsterisk;

        private final VersionRange optionalVersionRange;

        private CoordinateMatchingPattern(String pattern, String token, EnumSet<Coordinate> coordinates) {
            super(pattern);
            this.token = token;
            this.coordinates = coordinates;
            this.containsAsterisk = token.contains("*");
            this.containsWildcard = this.containsAsterisk || token.contains("?");
            if (!this.containsWildcard
                    && coordinates.equals(EnumSet.of(Coordinate.BASE_VERSION))
                    && (token.startsWith("[") || token.startsWith("("))) {
                try {
                    this.optionalVersionRange = VersionRange.createFromVersionSpec(token);
                } catch (InvalidVersionSpecificationException e) {
                    throw new IllegalArgumentException("Wrong version spec: " + token, e);
                }
            } else {
                this.optionalVersionRange = null;
            }
        }

        @Override
        public boolean matches(Artifactoid artifactoid) {
            for (Coordinate coordinate : coordinates) {
                String value = artifactoid.getCoordinate(coordinate);
                if (Coordinate.BASE_VERSION == coordinate && optionalVersionRange != null) {
                    if (optionalVersionRange.containsVersion(new DefaultArtifactVersion(value))) {
                        return true;
                    }
                } else if (containsWildcard) {
                    if (match(token, containsAsterisk, value)) {
                        return true;
                    }
                } else {
                    if (token.equals(value)) {
                        return true;
                    }
                }
            }
            return false;
        }
    }

    /**
     * Matches all input
     */
    private static class MatchAllPattern extends Pattern {
        private MatchAllPattern(String pattern) {
            super(pattern);
        }

        @Override
        public boolean matches(Artifactoid artifactoid) {
            return true;
        }
    }

    /**
     * Negative pattern
     */
    private static class NegativePattern extends Pattern {
        private final Pattern inner;

        private NegativePattern(String pattern, Pattern inner) {
            super(pattern);
            this.inner = inner;
        }

        @Override
        public boolean matches(Artifactoid artifactoid) {
            return inner.matches(artifactoid);
        }
    }

    // this beauty below must be salvaged

    @SuppressWarnings("InnerAssignment")
    private static boolean match(final String pattern, final boolean containsAsterisk, final String value) {
        char[] patArr = pattern.toCharArray();
        char[] strArr = value != null ? value.toCharArray() : new char[0];
        int patIdxStart = 0;
        int patIdxEnd = patArr.length - 1;
        int strIdxStart = 0;
        int strIdxEnd = strArr.length - 1;
        char ch;

        if (!containsAsterisk) {
            // No '*'s, so we make a shortcut
            if (patIdxEnd != strIdxEnd) {
                return false; // Pattern and string do not have the same size
            }
            for (int i = 0; i <= patIdxEnd; i++) {
                ch = patArr[i];
                if (ch != '?' && ch != strArr[i]) {
                    return false; // Character mismatch
                }
            }
            return true; // String matches against pattern
        }

        if (patIdxEnd == 0) {
            return true; // Pattern contains only '*', which matches anything
        }

        // Process characters before first star
        while ((ch = patArr[patIdxStart]) != '*' && strIdxStart <= strIdxEnd) {
            if (ch != '?' && ch != strArr[strIdxStart]) {
                return false; // Character mismatch
            }
            patIdxStart++;
            strIdxStart++;
        }
        if (strIdxStart > strIdxEnd) {
            // All characters in the string are used. Check if only '*'s are
            // left in the pattern. If so, we succeeded. Otherwise failure.
            for (int i = patIdxStart; i <= patIdxEnd; i++) {
                if (patArr[i] != '*') {
                    return false;
                }
            }
            return true;
        }

        // Process characters after last star
        while ((ch = patArr[patIdxEnd]) != '*' && strIdxStart <= strIdxEnd) {
            if (ch != '?' && ch != strArr[strIdxEnd]) {
                return false; // Character mismatch
            }
            patIdxEnd--;
            strIdxEnd--;
        }
        if (strIdxStart > strIdxEnd) {
            // All characters in the string are used. Check if only '*'s are
            // left in the pattern. If so, we succeeded. Otherwise failure.
            for (int i = patIdxStart; i <= patIdxEnd; i++) {
                if (patArr[i] != '*') {
                    return false;
                }
            }
            return true;
        }

        // process pattern between stars. padIdxStart and patIdxEnd point
        // always to a '*'.
        while (patIdxStart != patIdxEnd && strIdxStart <= strIdxEnd) {
            int patIdxTmp = -1;
            for (int i = patIdxStart + 1; i <= patIdxEnd; i++) {
                if (patArr[i] == '*') {
                    patIdxTmp = i;
                    break;
                }
            }
            if (patIdxTmp == patIdxStart + 1) {
                // Two stars next to each other, skip the first one.
                patIdxStart++;
                continue;
            }
            // Find the pattern between padIdxStart & padIdxTmp in str between
            // strIdxStart & strIdxEnd
            int patLength = (patIdxTmp - patIdxStart - 1);
            int strLength = (strIdxEnd - strIdxStart + 1);
            int foundIdx = -1;
            strLoop:
            for (int i = 0; i <= strLength - patLength; i++) {
                for (int j = 0; j < patLength; j++) {
                    ch = patArr[patIdxStart + j + 1];
                    if (ch != '?' && ch != strArr[strIdxStart + i + j]) {
                        continue strLoop;
                    }
                }

                foundIdx = strIdxStart + i;
                break;
            }

            if (foundIdx == -1) {
                return false;
            }

            patIdxStart = patIdxTmp;
            strIdxStart = foundIdx + patLength;
        }

        // All characters in the string are used. Check if only '*'s are left
        // in the pattern. If so, we succeeded. Otherwise failure.
        for (int i = patIdxStart; i <= patIdxEnd; i++) {
            if (patArr[i] != '*') {
                return false;
            }
        }
        return true;
    }
}
