/*
 * 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.jackrabbit.vault.validation;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.apache.commons.io.FilenameUtils;
import org.apache.jackrabbit.util.Text;
import org.apache.jackrabbit.vault.util.Constants;
import org.apache.jackrabbit.vault.util.PlatformNameFormat;
import org.apache.jackrabbit.vault.validation.impl.util.EnhancedBufferedInputStream;
import org.apache.jackrabbit.vault.validation.impl.util.ResettableInputStream;
import org.apache.jackrabbit.vault.validation.impl.util.ValidatorException;
import org.apache.jackrabbit.vault.validation.spi.DocumentViewXmlValidator;
import org.apache.jackrabbit.vault.validation.spi.FilterValidator;
import org.apache.jackrabbit.vault.validation.spi.GenericJcrDataValidator;
import org.apache.jackrabbit.vault.validation.spi.GenericMetaInfDataValidator;
import org.apache.jackrabbit.vault.validation.spi.JcrPathValidator;
import org.apache.jackrabbit.vault.validation.spi.MetaInfPathValidator;
import org.apache.jackrabbit.vault.validation.spi.NodeContext;
import org.apache.jackrabbit.vault.validation.spi.NodePathValidator;
import org.apache.jackrabbit.vault.validation.spi.OsgiConfigurationValidator;
import org.apache.jackrabbit.vault.validation.spi.PropertiesValidator;
import org.apache.jackrabbit.vault.validation.spi.ValidationContext;
import org.apache.jackrabbit.vault.validation.spi.ValidationMessage;
import org.apache.jackrabbit.vault.validation.spi.ValidationMessageSeverity;
import org.apache.jackrabbit.vault.validation.spi.Validator;
import org.apache.jackrabbit.vault.validation.spi.impl.AdvancedFilterValidator;
import org.apache.jackrabbit.vault.validation.spi.impl.AdvancedPropertiesValidator;
import org.apache.jackrabbit.vault.validation.spi.impl.DocumentViewParserValidator;
import org.apache.jackrabbit.vault.validation.spi.impl.DocumentViewParserValidatorFactory;
import org.apache.jackrabbit.vault.validation.spi.impl.OsgiConfigurationParserValidator;
import org.apache.jackrabbit.vault.validation.spi.impl.OsgiConfigurationParserValidatorFactory;
import org.apache.jackrabbit.vault.validation.spi.util.NodeContextImpl;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Provides methods to call all registered validators. This instance is bound to the {@link ValidationContext} being given in the
 * {@link ValidationExecutorFactory#createValidationExecutor(org.apache.jackrabbit.vault.validation.spi.ValidationContext, boolean, boolean, Map)}.
 * This class is thread-safe (i.e. methods can be used from different threads on the same instance).
 * @see ValidationExecutorFactory
 */
public final class ValidationExecutor {

    public static final String EXTENSION_BINARY = ".binary";
    private final Map<@NotNull String, @NotNull DocumentViewXmlValidator> documentViewXmlValidators;
    private final Map<@NotNull String, @NotNull NodePathValidator> nodePathValidators;
    private final Map<@NotNull String, @NotNull GenericJcrDataValidator> genericJcrDataValidators;
    private final Map<@NotNull String, @NotNull GenericMetaInfDataValidator> genericMetaInfDataValidators;
    private final Map<@NotNull String, @NotNull MetaInfPathValidator> metaInfPathValidators;
    private final Map<@NotNull String, @NotNull JcrPathValidator> jcrPathValidators;
    private final Map<@NotNull String, @NotNull FilterValidator> filterValidators;
    private final Map<@NotNull String, @NotNull PropertiesValidator> propertiesValidators;
    private final @NotNull Map<@NotNull String, @NotNull Validator> validatorsById;
    private final Map<@NotNull String, @NotNull OsgiConfigurationValidator> osgiConfigurationValidators;

    /**
     * the default logger
     */
    private static final Logger log = LoggerFactory.getLogger(ValidationExecutor.class);

    /**
     * Creates a new instance with the given validators.
     *
     * @param validatorsById a map of validator ids and actual validators
     */
    public ValidationExecutor(@NotNull Map<@NotNull String, @NotNull Validator> validatorsById) {
        this.validatorsById = new LinkedHashMap<>(validatorsById);
        this.osgiConfigurationValidators =
                ValidationExecutor.filterValidatorsByClass(this.validatorsById, OsgiConfigurationValidator.class);
        if (osgiConfigurationValidators.isEmpty()) {
            // remove irrelevant parser in case no downstream validators are registered
            this.validatorsById.remove(OsgiConfigurationParserValidatorFactory.ID);
        }
        this.documentViewXmlValidators =
                ValidationExecutor.filterValidatorsByClass(this.validatorsById, DocumentViewXmlValidator.class);
        this.nodePathValidators =
                ValidationExecutor.filterValidatorsByClass(this.validatorsById, NodePathValidator.class);
        this.genericJcrDataValidators =
                ValidationExecutor.filterValidatorsByClass(this.validatorsById, GenericJcrDataValidator.class);
        this.genericMetaInfDataValidators =
                ValidationExecutor.filterValidatorsByClass(this.validatorsById, GenericMetaInfDataValidator.class);
        this.metaInfPathValidators =
                ValidationExecutor.filterValidatorsByClass(this.validatorsById, MetaInfPathValidator.class);
        this.jcrPathValidators =
                ValidationExecutor.filterValidatorsByClass(this.validatorsById, JcrPathValidator.class);
        this.filterValidators = ValidationExecutor.filterValidatorsByClass(this.validatorsById, FilterValidator.class);
        this.propertiesValidators =
                ValidationExecutor.filterValidatorsByClass(this.validatorsById, PropertiesValidator.class);
        // nested validators (i.e. ones called from specific low-level validators) need to be linked
        for (Validator validator : validatorsById.values()) {
            if (validator instanceof AdvancedFilterValidator) {
                AdvancedFilterValidator.class.cast(validator).setFilterValidators(filterValidators);
            }
            if (validator instanceof AdvancedPropertiesValidator) {
                AdvancedPropertiesValidator.class.cast(validator).setPropertiesValidators(propertiesValidators);
            }
            if (validator instanceof DocumentViewParserValidator) {
                DocumentViewParserValidator.class
                        .cast(validator)
                        .setDocumentViewXmlValidators(documentViewXmlValidators);
            }
            if (validator instanceof OsgiConfigurationParserValidator) {
                OsgiConfigurationParserValidator.class
                        .cast(validator)
                        .setOsgiConfigurationValidators(osgiConfigurationValidators);
            }
        }

        // jcr path validators requires documentviewparservalidator (as that retrieves the JCR paths in the first place)
        if (!(jcrPathValidators.isEmpty() || nodePathValidators.isEmpty())
                && !genericJcrDataValidators.containsKey(DocumentViewParserValidatorFactory.ID)) {
            Set<String> pathValidatorIds = new HashSet<>(jcrPathValidators.keySet());
            pathValidatorIds.addAll(nodePathValidators.keySet());
            throw new IllegalStateException("For the validators with id(s) '" + String.join(", ", pathValidatorIds)
                    + "' it is mandatory to also enable validator '" + DocumentViewParserValidatorFactory.ID + "'");
        }
    }

    /**
     * Returns all bound validators by id.
     * @return a map with all validators (key=validator id, value=actual validator)
     */
    public @NotNull Map<String, Validator> getAllValidatorsById() {
        return validatorsById;
    }

    /**
     * Returns all unused validators by id. Unused validators are those implementing an interface which
     * is not understood by this executor.
     *
     * @return a map with all unused validators (key=validator id, value=actual validator)
     */
    public @NotNull Map<String, Validator> getUnusedValidatorsById() {
        Map<String, Validator> unusedValidators = new HashMap<>(validatorsById);
        unusedValidators.keySet().removeAll(documentViewXmlValidators.keySet());
        unusedValidators.keySet().removeAll(nodePathValidators.keySet());
        unusedValidators.keySet().removeAll(metaInfPathValidators.keySet());
        unusedValidators.keySet().removeAll(jcrPathValidators.keySet());
        unusedValidators.keySet().removeAll(genericJcrDataValidators.keySet());
        unusedValidators.keySet().removeAll(genericMetaInfDataValidators.keySet());
        // plus the ones bound to other validators
        unusedValidators.keySet().removeAll(filterValidators.keySet());
        unusedValidators.keySet().removeAll(propertiesValidators.keySet());
        unusedValidators.keySet().removeAll(osgiConfigurationValidators.keySet());
        return unusedValidators;
    }

    /**
     * Validates a package's META-INF input stream  with all relevant validators.
     *
     * @param input the input stream if it is a file or {@code null} in case it is called for a folder. It is not closed during processing, this is obligation of the caller. Should not be buffered as buffering is done internally!
     * @param filePath should be relative to the META-INF directory (i.e. should not start with {@code META-INF})
     * @param basePath the path to which the file path is relative
     * @return the list of validation messages
     * @throws IOException in case the input stream could not be accessed */
    public @NotNull Collection<ValidationViolation> validateMetaInf(
            @Nullable InputStream input, @NotNull Path filePath, @NotNull Path basePath) throws IOException {
        if (filePath.isAbsolute()) {
            throw new IllegalArgumentException("Given file path must not be absolute");
        }
        if (filePath.startsWith(Constants.META_INF)) {
            throw new IllegalArgumentException(
                    "Given file path must not start with META-INF but rather on the level below");
        }
        List<ValidationViolation> messages = new LinkedList<>();
        messages.add(new ValidationViolation(
                ValidationMessageSeverity.DEBUG, "Validating meta inf file '" + filePath + "'..."));
        messages.addAll(validateGenericMetaInfData(
                input != null ? new EnhancedBufferedInputStream(input) : null, filePath, basePath));
        return messages;
    }

    /**
     * Validates a package's input stream (below jcr_root, no metadata) with all relevant validators.
     * <p>
     * As some validators rely on the order of validated files, make sure to first call for streams representing the parent nodes (i.e. folders and .content.xml streams), before calling it for streams representing the children.
     *
     * @param input the input stream if it is a file or {@code null} in case it is called for a folder. It is not closed during processing, this is obligation of the caller. Should not be buffered as buffering is done internally!
     * @param filePath file path relative to the content package jcr root (i.e. the folder named "jcr_root")
     * @param basePath the path to which the file path is relative
     * @return the list of validation messages
     * @throws IOException in case the input stream could not be accessed
     */
    public @NotNull Collection<ValidationViolation> validateJcrRoot(
            @Nullable InputStream input, @NotNull Path filePath, @NotNull Path basePath) throws IOException {
        if (filePath.isAbsolute()) {
            throw new IllegalArgumentException("Given path is not relative " + filePath);
        }
        if (filePath.startsWith(Constants.ROOT_DIR)) {
            throw new IllegalArgumentException(
                    "Given file path must not start with jcr_root but rather on the level below");
        }
        List<ValidationViolation> messages = new LinkedList<>();
        messages.add(
                new ValidationViolation(ValidationMessageSeverity.DEBUG, "Validating jcr file '" + filePath + "'..."));
        messages.addAll(validateGenericJcrData(
                input != null ? new EnhancedBufferedInputStream(input) : null, filePath, basePath));
        return messages;
    }

    /**
     * Must be called at the end of the validation (when the validation context is no longer used).
     * This is important as some validators emit violation messages only when this method is called.
     *
     * @return the list of additional validation violations (might be empty) which have not been reported before
     */
    public @NotNull Collection<ValidationViolation> done() {
        Collection<ValidationViolation> allViolations = new LinkedList<>();
        // go through all validators (even the nested ones)
        for (Map.Entry<@NotNull String, @NotNull Validator> entry : validatorsById.entrySet()) {
            try {
                Collection<ValidationMessage> violations = entry.getValue().done();
                if (violations != null && !violations.isEmpty()) {
                    allViolations.addAll(
                            ValidationViolation.wrapMessages(entry.getKey(), violations, null, null, null, 0, 0));
                }
            } catch (RuntimeException e) {
                throw new ValidatorException(entry.getKey(), e);
            }
        }
        return allViolations;
    }

    private Collection<ValidationViolation> validateNodePaths(
            Path filePath, Path basePath, Map<String, Integer> nodePathsAndLineNumbers) {
        List<ValidationViolation> enrichedMessages = new LinkedList<>();

        for (Map.Entry<String, Integer> nodePathAndLineNumber : nodePathsAndLineNumbers.entrySet()) {
            for (Map.Entry<String, NodePathValidator> entry : nodePathValidators.entrySet()) {
                enrichedMessages.add(
                        new ValidationViolation(entry.getKey(), ValidationMessageSeverity.DEBUG, "Validate..."));
                try {
                    Collection<ValidationMessage> messages = entry.getValue()
                            .validate(new NodeContextImpl(nodePathAndLineNumber.getKey(), filePath, basePath));
                    if (messages != null && !messages.isEmpty()) {
                        enrichedMessages.addAll(ValidationViolation.wrapMessages(
                                entry.getKey(),
                                messages,
                                filePath,
                                basePath,
                                nodePathAndLineNumber.getKey(),
                                nodePathAndLineNumber.getValue().intValue(),
                                0));
                    }
                } catch (RuntimeException e) {
                    throw new ValidatorException(entry.getKey(), nodePathAndLineNumber.getKey(), filePath, e);
                }
            }
        }
        return enrichedMessages;
    }

    private Collection<ValidationViolation> validateGenericMetaInfData(
            @Nullable InputStream input, @NotNull Path filePath, @NotNull Path basePath) throws IOException {
        Collection<ValidationViolation> enrichedMessages = new LinkedList<>();
        for (Map.Entry<String, MetaInfPathValidator> entry : metaInfPathValidators.entrySet()) {
            Collection<ValidationMessage> messages =
                    entry.getValue().validateMetaInfPath(filePath, basePath, input == null);
            if (messages != null && !messages.isEmpty()) {
                enrichedMessages.addAll(
                        ValidationViolation.wrapMessages(entry.getKey(), messages, filePath, basePath, null, 0, 0));
            }
        }
        if (input != null) {
            InputStream currentInput = input;
            ResettableInputStream resettableInputStream = null;
            try {
                for (Map.Entry<String, GenericMetaInfDataValidator> entry : genericMetaInfDataValidators.entrySet()) {
                    try {
                        GenericMetaInfDataValidator validator = entry.getValue();
                        if (validator.shouldValidateMetaInfData(filePath, basePath)) {
                            if (resettableInputStream == null) {
                                boolean isAnotherValidatorInterested = genericMetaInfDataValidators.values().stream()
                                        .filter(t -> !t.equals(validator))
                                        .anyMatch(x -> x.shouldValidateMetaInfData(filePath, basePath));
                                if (isAnotherValidatorInterested) {
                                    currentInput = resettableInputStream = new ResettableInputStream(input);
                                }
                            } else {
                                resettableInputStream.reset();
                            }
                            enrichedMessages.add(new ValidationViolation(
                                    entry.getKey(), ValidationMessageSeverity.DEBUG, "Validate..."));
                            Collection<ValidationMessage> messages =
                                    validator.validateMetaInfData(currentInput, filePath, basePath);
                            if (messages != null && !messages.isEmpty()) {
                                enrichedMessages.addAll(ValidationViolation.wrapMessages(
                                        entry.getKey(), messages, filePath, basePath, null, 0, 0));
                            }
                        }
                    } catch (RuntimeException e) {
                        if (!(e instanceof ValidatorException)) {
                            throw new ValidatorException(entry.getKey(), filePath, e);
                        }
                    }
                }
            } finally {
                if (resettableInputStream != null) {
                    resettableInputStream.close();
                }
            }
        }
        return enrichedMessages;
    }

    private Collection<ValidationViolation> validateGenericJcrData(
            @Nullable InputStream input, @NotNull Path filePath, @NotNull Path basePath) throws IOException {
        Map<String, Integer> nodePathsAndLineNumbers = new LinkedHashMap<>();
        Collection<ValidationViolation> enrichedMessages = new LinkedList<>();

        boolean isDocViewXml = true;
        if (input != null) {
            InputStream currentInput = input;
            ResettableInputStream resettableInputStream = null;
            try {
                // make sure the docviewparser always comes first
                for (Map.Entry<String, GenericJcrDataValidator> entry : genericJcrDataValidators.entrySet()) {
                    try {
                        GenericJcrDataValidator validator = entry.getValue();
                        log.debug(
                                "Validate {} with validator '{}'",
                                filePath,
                                validator.getClass().getName());
                        if (validator.shouldValidateJcrData(filePath, basePath)) {
                            if (resettableInputStream == null) {
                                boolean isAnotherValidatorInterested = genericJcrDataValidators.values().stream()
                                        .filter(t -> !t.equals(validator))
                                        .anyMatch(x -> x.shouldValidateJcrData(filePath, basePath));
                                if (isAnotherValidatorInterested) {
                                    currentInput = resettableInputStream = new ResettableInputStream(input);
                                }
                            } else {
                                resettableInputStream.reset();
                            }
                            enrichedMessages.add(new ValidationViolation(
                                    entry.getKey(), ValidationMessageSeverity.DEBUG, "Validate..."));
                            Collection<ValidationMessage> messages = validator.validateJcrData(
                                    currentInput, filePath, basePath, nodePathsAndLineNumbers);
                            if (messages != null && !messages.isEmpty()) {
                                enrichedMessages.addAll(ValidationViolation.wrapMessages(
                                        entry.getKey(), messages, filePath, basePath, null, 0, 0));
                            }
                        }
                        // if we haven't collected node paths from a previous run the input is no docview xml
                        if (nodePathsAndLineNumbers.isEmpty()) {
                            // convert file name to node path
                            String nodePath = filePathToNodePath(filePath);
                            log.debug("Found non-docview node '{}'", nodePath);
                            isDocViewXml = false;
                            nodePathsAndLineNumbers.put(nodePath, 0);
                        }
                    } catch (RuntimeException e) {
                        if (!(e instanceof ValidatorException)) {
                            throw new ValidatorException(entry.getKey(), filePath, e);
                        } else {
                            throw e;
                        }
                    }
                }
            } finally {
                if (resettableInputStream != null) {
                    resettableInputStream.close();
                }
            }
        } else {
            // collect node path for folder only
            nodePathsAndLineNumbers.put(filePathToNodePath(filePath), 0);
        }

        if (!jcrPathValidators.isEmpty() || !nodePathValidators.isEmpty()) {
            NodeContext nodeContext = new NodeContextImpl(
                    nodePathsAndLineNumbers.keySet().stream()
                            .findFirst()
                            .orElseThrow(() -> new IllegalStateException("No node paths have been collected")),
                    filePath,
                    basePath);
            for (Map.Entry<String, JcrPathValidator> entry : jcrPathValidators.entrySet()) {
                Collection<ValidationMessage> messages =
                        entry.getValue().validateJcrPath(nodeContext, input == null, isDocViewXml);
                if (messages != null && !messages.isEmpty()) {
                    enrichedMessages.addAll(
                            ValidationViolation.wrapMessages(entry.getKey(), messages, filePath, basePath, null, 0, 0));
                }
            }
            enrichedMessages.addAll(validateNodePaths(filePath, basePath, nodePathsAndLineNumbers));
        }
        return enrichedMessages;
    }

    /**
     * Converts the given file path (a relative one) to the absolute node path.
     * @param filePath the relative file path to convert
     * @return the node path
     */
    public static @NotNull String filePathToNodePath(@NotNull Path filePath) {
        String platformPath = FilenameUtils.separatorsToUnix(filePath.toString());
        // treat https://jackrabbit.apache.org/filevault/vaultfs.html#Binary_Properties specially
        if (platformPath.endsWith(EXTENSION_BINARY)) {
            platformPath = Text.getRelativeParent(platformPath, 1);
        }
        String repositoryPath = PlatformNameFormat.getRepositoryPath(platformPath, true);
        if (!repositoryPath.isEmpty()) {
            // make repository path absolute by prefixing it with "/" in case it is not the root node path itself
            repositoryPath = "/" + repositoryPath;
        } else {
            repositoryPath = "/";
        }
        return repositoryPath;
    }

    static <@NotNull T> @NotNull Map<@NotNull String, @NotNull T> filterValidatorsByClass(
            @NotNull Map<@NotNull String, @NotNull Validator> allValidators, @NotNull Class<T> type) {
        return allValidators.entrySet().stream()
                .filter(x -> type.isInstance(x.getValue()))
                // keep map order
                .collect(Collectors.toMap(
                        Map.Entry::getKey,
                        x -> type.cast(x.getValue()),
                        (u, v) -> {
                            throw new IllegalStateException(String.format(Locale.ENGLISH, "Duplicate key %s", u));
                        },
                        LinkedHashMap::new));
    }
}
