/*
 * 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.sling.feature.cpconverter.accesscontrol;

import org.apache.jackrabbit.spi.Name;
import org.apache.jackrabbit.spi.PrivilegeDefinition;
import org.apache.jackrabbit.spi.commons.conversion.DefaultNamePathResolver;
import org.apache.jackrabbit.spi.commons.conversion.NameResolver;
import org.apache.jackrabbit.util.Text;
import org.apache.jackrabbit.vault.fs.spi.PrivilegeDefinitions;
import org.apache.sling.feature.cpconverter.ConverterException;
import org.apache.sling.feature.cpconverter.features.FeaturesManager;
import org.apache.sling.feature.cpconverter.repoinit.NoOpVisitor;
import org.apache.sling.feature.cpconverter.repoinit.OperationProcessor;
import org.apache.sling.feature.cpconverter.repoinit.createpath.CreatePathSegmentProcessor;
import org.apache.sling.feature.cpconverter.shared.ConverterConstants;
import org.apache.sling.feature.cpconverter.shared.RepoPath;
import org.apache.sling.feature.cpconverter.vltpkg.VaultPackageAssembler;
import org.apache.sling.repoinit.parser.RepoInitParsingException;
import org.apache.sling.repoinit.parser.impl.RepoInitParserService;
import org.apache.sling.repoinit.parser.operations.CreateServiceUser;
import org.apache.sling.repoinit.parser.operations.DisableServiceUser;
import org.apache.sling.repoinit.parser.operations.Operation;
import org.apache.sling.repoinit.parser.operations.AclLine;
import org.apache.sling.repoinit.parser.operations.CreatePath;
import org.apache.sling.repoinit.parser.operations.RegisterNodetypes;
import org.apache.sling.repoinit.parser.operations.RegisterPrivilege;
import org.apache.sling.repoinit.parser.operations.SetAclPrincipalBased;
import org.apache.sling.repoinit.parser.operations.SetAclPrincipals;
import org.apache.sling.repoinit.parser.operations.WithPathOptions;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jcr.NamespaceException;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Optional;
import java.util.Formatter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class DefaultAclManager implements AclManager, EnforceInfo {

    private static final Logger log = LoggerFactory.getLogger(DefaultAclManager.class);
    
    private final RepoPath enforcePrincipalBasedSupportedPath;
    private final String systemRelPath;
    private final boolean alwaysForceSystemUserPath;

    private final OperationProcessor processor = new OperationProcessor();

    private final Set<SystemUser> systemUsers = new LinkedHashSet<>();
    private final Set<SystemUser> globalSystemUsers = new LinkedHashSet<>();
    private final Set<String> systemUserIds = new LinkedHashSet<>();

    private final Set<Group> groups = new LinkedHashSet<>();
    private final Set<User> users = new LinkedHashSet<>();
    private final Set<Mapping> mappings = new HashSet<>();
    private final Set<String> mappedById = new HashSet<>();

    private final Map<String, List<AccessControlEntry>> acls = new HashMap<>();

    private final List<RegisterNodetypes> nodetypeOperations = new LinkedList<>();

    private volatile PrivilegeDefinitions privilegeDefinitions;
    
    private RepoPath userRootPath;

    /**
     * Same as {@code DefaultAclManager(null, "system", false)}
     * @see ConverterConstants#SYSTEM_USER_REL_PATH_DEFAULT
     */
    public DefaultAclManager() {
        this(null, ConverterConstants.SYSTEM_USER_REL_PATH_DEFAULT, false);
    }

    /**
     * @param enforcePrincipalBasedSupportedPath The supported path if principal-based access control setup for service users should be enforced; {@code null} otherwise.
     * @param systemRelPath The relative intermediate path used for all system users.
     * @deprecated Use DefaultAclManager(String,String,boolean) instead
     */
    @Deprecated
    public DefaultAclManager(@Nullable String enforcePrincipalBasedSupportedPath, @NotNull String systemRelPath) {
        this(enforcePrincipalBasedSupportedPath, systemRelPath, false);
        log.warn("Deprecated. Please refactor to use DefaultAclManager(String,String,boolean) instead");
    }

    /**
     * Creates a new instance of {@code DefaultAclManager}.
     *
     * @param enforcePrincipalBasedSupportedPath The supported path if principal-based access control setup for service users should be enforced; {@code null} otherwise.
     * @param systemRelPath The relative intermediate path used for all system users.
     * @param alwaysForceSystemUserPath Option to make sure all system users are being created with the specified intermediate path (i.e. translating to 'with forced path' statements in repoinit).
     */
    public DefaultAclManager(@Nullable String enforcePrincipalBasedSupportedPath, @NotNull String systemRelPath, boolean alwaysForceSystemUserPath)  {
        if (enforcePrincipalBasedSupportedPath != null && !enforcePrincipalBasedSupportedPath.contains(systemRelPath)) {
            throw new RuntimeException("Relative path for system users "+ systemRelPath + " not included in " + enforcePrincipalBasedSupportedPath);
        }
        this.enforcePrincipalBasedSupportedPath = (enforcePrincipalBasedSupportedPath == null) ? null : new RepoPath(enforcePrincipalBasedSupportedPath);
        this.systemRelPath = systemRelPath;
        this.alwaysForceSystemUserPath = alwaysForceSystemUserPath;
    }

    @Override
    public boolean addUser(@NotNull User user) {
        return users.add(user);
    }

    @Override
    public boolean addGroup(@NotNull Group group) {
        return groups.add(group);
    }

    @Override
    public boolean addSystemUser(@NotNull SystemUser systemUser) {
        globalSystemUsers.add(systemUser);
        if (systemUsers.add(systemUser)) {
            recordSystemUserIds(systemUser.getId());
            setUserRoot(systemUser.getPath());
            return true;
        } else {
            return false;
        }

    }

    @Override
    public void addMapping(@NotNull Mapping mapping) {
        if (mappings.add(mapping)) {
            for (SystemUser user : globalSystemUsers) {
                if (mapping.mapsUser(user.getId())) {
                    mappedById.add(user.getId());
                }
            }
        }
    }

    @Override
    public boolean addAccessControlEntry(@NotNull String systemUser, @NotNull AccessControlEntry acl) {
        if (globalSystemUsers.stream().filter(su ->  su.getId().equals(systemUser)).findFirst().isPresent()) {
            acls.computeIfAbsent(systemUser, k -> new LinkedList<>()).add(acl);
            return true;
        }
        return false;
    }

    @Override
    public void addRepoinitExtension(@NotNull List<VaultPackageAssembler> packageAssemblers, @NotNull FeaturesManager featureManager)
    throws IOException, ConverterException {
        try (Formatter formatter = new Formatter()) {

            if (privilegeDefinitions != null) {
                registerPrivileges(privilegeDefinitions, formatter);
            }

            for (RegisterNodetypes op : nodetypeOperations) {
                formatter.format("%s", op.asRepoInitString());
            }

            addUsersAndGroups(formatter);
            addPaths(formatter, packageAssemblers);

            // add the acls
            acls.forEach((systemUserID, authorizations) ->
                    globalSystemUsers.stream().filter(su ->  su.getId().equals(systemUserID)).findFirst().ifPresent(systemUser ->
                            addStatements(systemUser, authorizations, formatter)
                    ));

            String text = formatter.toString();

            if (!text.isEmpty()) {
                featureManager.addOrAppendRepoInitExtension("content-package", text, null);
            }
        }
    }

    @Override
    public void addRepoinitExtention(@NotNull String source, @Nullable String repoInitText, @Nullable String runMode, @NotNull FeaturesManager featuresManager)
    throws IOException, ConverterException {
        if (repoInitText == null || repoInitText.trim().isEmpty()) {
            return;
        }

        if ("seed".equalsIgnoreCase(runMode)) {
            try {
                List<Operation> ops = new RepoInitParserService().parse(new StringReader(repoInitText));
                for (Operation op : ops) {
                    op.accept(new NoOpVisitor() {
                        @Override
                        public void visitCreateServiceUser(CreateServiceUser createServiceUser) {
                            recordSystemUserIds(createServiceUser.getUsername());
                        }
                    });
                }
            } catch (RepoInitParsingException e) {
                throw new ConverterException(e.getMessage(), e);
            }
            return;
        }
        try (Formatter formatter = new Formatter()) {
            if (enforcePrincipalBased() || alwaysForceSystemUserPath) {
                List<Operation> ops = new RepoInitParserService().parse(new StringReader(repoInitText));
                processor.apply(ops, formatter,this);
            } else {
                formatter.format("%s", repoInitText);
            }

            String text = formatter.toString().trim();
            if (!text.isEmpty()) {
                featuresManager.addOrAppendRepoInitExtension(source, text, runMode);
            }
        } catch (RepoInitParsingException e) {
            throw new ConverterException(e.getMessage(), e);
        }
    }

    private void addUsersAndGroups(@NotNull Formatter formatter) throws ConverterException {
        for (SystemUser systemUser : systemUsers) {
            // make sure all system users are created first
            boolean withForcedPath = (alwaysForceSystemUserPath || enforcePrincipalBased(systemUser));
            CreateServiceUser operation = new CreateServiceUser(systemUser.getId(), new WithPathOptions(calculateIntermediatePath(systemUser), withForcedPath));
            formatter.format("%s", operation.asRepoInitString());
            if (systemUser.getDisabledReason() != null) {
                DisableServiceUser disable = new DisableServiceUser(systemUser.getId(), systemUser.getDisabledReason());
                disable.setServiceUser(true);
                formatter.format("%s", disable.asRepoInitString());
            }

            if (aclIsBelow(systemUser.getPath())) {
                throw new ConverterException("Detected policy on subpath of system-user: " + systemUser);
            }
        }

        // abort the conversion if an access control entry takes effect at or below a user/group which is not
        // created by repo-init statements generated here.
        for(final Group g : groups) {
            if (aclStartsWith(g.getPath())) {
                throw new ConverterException("Detected policy on group: " + g);
            }
        }
        for(final User u : users) {
            if (aclStartsWith(u.getPath())) {
                throw new ConverterException("Detected policy on user: " + u);
            }
        }
    }

    @NotNull
    private String calculateIntermediatePath(@NotNull SystemUser systemUser) throws ConverterException {
        RepoPath intermediatePath = systemUser.getIntermediatePath();
        if (enforcePrincipalBased(systemUser)) {
            return calculateEnforcedIntermediatePath(intermediatePath.toString());
        } else {
            return getRelativeIntermediatePath(intermediatePath.toString());
        }
    }

    private void addPaths(@NotNull Formatter formatter, @NotNull List<VaultPackageAssembler> packageAssemblers) {
        Set<RepoPath> paths = acls.entrySet().stream()
                // filter paths if service user does not exist or will have principal-based ac setup enforced
                .filter(entry -> {
                    Optional<SystemUser> su = getSystemUser(entry.getKey());
                    return su.isPresent() && !enforcePrincipalBased(su.get());
                })
                .map(Entry::getValue)
                .flatMap(Collection::stream)
                // paths only should/need to be create with resource-based access control
                .filter(((Predicate<AccessControlEntry>) AccessControlEntry::isPrincipalBased).negate())
                .map(AccessControlEntry::getRepositoryPath)
                .collect(Collectors.toSet());

        paths.stream()
                .filter(path -> paths.stream().noneMatch(other -> !other.equals(path) && other.startsWith(path)))
                .filter(((Predicate<RepoPath>)RepoPath::isRepositoryPath).negate())
                .filter(path -> Stream.of(systemUsers, users, groups).flatMap(Collection::stream)
                        .noneMatch(user -> user.getPath().startsWith(path)))
                .map(path -> getCreatePath(path, packageAssemblers))
                .filter(Objects::nonNull)
                .forEach(
                        path -> formatter.format("%s", path.asRepoInitString())
                );
    }

    private boolean aclStartsWith(@NotNull RepoPath path) {
        return acls.values().stream().flatMap(List::stream).anyMatch(acl -> acl.getRepositoryPath().startsWith(path));
    }

    private boolean aclIsBelow(@NotNull RepoPath path) {
        return acls.values().stream().flatMap(List::stream).anyMatch(acl -> acl.getRepositoryPath().startsWith(path) && !acl.getRepositoryPath().equals(path));
    }

    private void addStatements(@NotNull SystemUser systemUser,
                               @NotNull List<AccessControlEntry> authorizations,
                               @NotNull Formatter formatter) {
        Map<AccessControlEntry, String> resourceEntries = new LinkedHashMap<>();
        Map<AccessControlEntry, String> principalEntries = new LinkedHashMap<>();

        authorizations.forEach(entry -> {
            String path = getRepoInitPath(entry.getRepositoryPath(), systemUser);
            if (entry.isPrincipalBased() || enforcePrincipalBased(systemUser)) {
                principalEntries.put(entry, path);
            } else {
                resourceEntries.put(entry, path);
            }
        });

        if (!principalEntries.isEmpty()) {
            SetAclPrincipalBased operation = new SetAclPrincipalBased(Collections.singletonList(systemUser.getId()), asAcLines(principalEntries));
            formatter.format("%s", operation.asRepoInitString());
        }
        if (!resourceEntries.isEmpty()) {
            SetAclPrincipals operation = new SetAclPrincipals(Collections.singletonList(systemUser.getId()), asAcLines(resourceEntries));
            formatter.format("%s", operation.asRepoInitString());
        }
    }

    private static List<AclLine> asAcLines(@NotNull Map<AccessControlEntry, String> entries) {
        List<AclLine> lines = new ArrayList<>();
        entries.forEach((entry, path) -> lines.add(entry.asAclLine(path)));
        return lines;
    }

    private boolean enforcePrincipalBased() {
        return enforcePrincipalBasedSupportedPath != null;
    }

    private boolean enforcePrincipalBased(@NotNull SystemUser systemUser) {
        return enforcePrincipalBased(systemUser.getId());
    }

    private @NotNull Optional<SystemUser> getSystemUser(@NotNull String id) {
        return systemUsers.stream().filter(systemUser ->  systemUser.getId().equals(id)).findFirst();
    }

    @Override
    public void addNodetypeRegistration(@NotNull String cndStatements) {
        nodetypeOperations.add(new RegisterNodetypes(cndStatements));
    }

    @Override
    public void addPrivilegeDefinitions(@NotNull PrivilegeDefinitions privilegeDefinitions) {
        this.privilegeDefinitions = privilegeDefinitions;
    }

    @Override
    public void reset() {
        systemUsers.clear();
        acls.clear();
        nodetypeOperations.clear();
        privilegeDefinitions = null;
    }

    @Override
    public void recordSystemUserIds(@NotNull String... systemUserIds) {
        for (String id : systemUserIds) {
            if (this.systemUserIds.add(id) && mappings.stream().anyMatch(mapping -> mapping.mapsUser(id))) {
                mappedById.add(id);
            }
        }
    }

    @Override
    public boolean enforcePrincipalBased(@NotNull String systemUserId) {
        if (enforcePrincipalBased() && systemUserIds.contains(systemUserId)) {
            if (mappedById.contains(systemUserId)) {
                log.warn("Skip enforcing principal-based access control setup for system user '{}' due to existing mapping by id.", systemUserId);
                return false;
            } else {
                return true;
            }
        }
        return false;
    }

    @Override
    public boolean enforcePath(@NotNull String systemUserId) {
        // NOTE: defined by a global configuration option and thus the 'systemUserId' is ignored
        return alwaysForceSystemUserPath;
    }

    @Override
    @NotNull
    public String calculateEnforcedIntermediatePath(@Nullable String intermediatePath) throws ConverterException {
        if (enforcePrincipalBasedSupportedPath == null) {
            throw new IllegalStateException("No supported path configured");
        }
        String supportedPath = getRelativeIntermediatePath(enforcePrincipalBasedSupportedPath.toString());
        if (intermediatePath == null || intermediatePath.isEmpty()) {
            return supportedPath;
        }

        String relIntermediate = getRelativeIntermediatePath(intermediatePath);
        if (Text.isDescendantOrEqual(supportedPath, relIntermediate)) {
            return relIntermediate;
        } else if (Text.isDescendant(relIntermediate, supportedPath)) {
            return supportedPath;
        } else {
            String parent = Text.getRelativeParent(relIntermediate, 1);
            while (!parent.isEmpty() && !"/".equals(parent)) {
                if (Text.isDescendantOrEqual(parent, supportedPath)) {
                    String relpath = relIntermediate.substring(parent.length());
                    return supportedPath + relpath;
                }
                parent = Text.getRelativeParent(parent, 1);
            }
            throw new ConverterException("Cannot calculate intermediate path for service user. Configured Supported path " +enforcePrincipalBasedSupportedPath+" has no common ancestor with "+intermediatePath);
        }
    }

    @NotNull
    private String getRelativeIntermediatePath(@NotNull String intermediatePath) throws ConverterException {
        if (intermediatePath.equals(systemRelPath) || intermediatePath.startsWith(systemRelPath+"/")) {
            return intermediatePath;
        } else {
            String p = intermediatePath + "/";
            String rel = "/" + systemRelPath + "/";
            int i = p.indexOf(rel);
            if (i == -1) {
                throw new ConverterException("Invalid intermediate path for system user " + intermediatePath + ". Must include "+ systemRelPath);
            }
            return intermediatePath.substring(i + 1);
        }
    }

    protected @Nullable CreatePath getCreatePath(@NotNull RepoPath path, @NotNull List<VaultPackageAssembler> packageAssemblers) {
        if (path.getParent() == null) {
            log.debug("Omit create path statement for path '{}'", path);
            return null;
        }
        
        CreatePath cp = new CreatePath(null);
        boolean foundType = CreatePathSegmentProcessor.processSegments(path, packageAssemblers, cp);
        
        if (!foundType && isBelowUserRoot(path)) {
            // if no type information has been detected, don't issue a 'create path' statement for nodes below the 
            // user-root 
            log.warn("Failed to extract primary type information for node at path '{}'", path);
            return null;
        } else {
            // assume that primary type information is present or can be extracted from default primary type definition 
            // of the the top-level nodes (i.e. their effective node type definition).
            return cp;
        }
    }

    @NotNull
    private String getRepoInitPath(@NotNull RepoPath path, @NotNull SystemUser systemUser) {
        if (path.isRepositoryPath()) {
            return ":repository";
        } else if (isHomePath(path, systemUser.getPath())) {
            return getHomePath(systemUser);
        } else {
            AbstractUser other = getOtherUser(path, Stream.of(systemUsers, groups).flatMap(Collection::stream));
            if (other != null) {
                return getHomePath(other);
            }
            // not a special path
            return path.toString();
        }
    }

    private static boolean isHomePath(@NotNull RepoPath path, @NotNull RepoPath systemUserPath) {
        // ACE located in the subtree are not supported
        return path.equals(systemUserPath);
    }

    @Nullable
    private static AbstractUser getOtherUser(@NotNull RepoPath path, @NotNull Stream<? extends AbstractUser> abstractUsers) {
        return abstractUsers.filter(au -> path.startsWith(au.getPath())).findFirst().orElse(null);
    }

    @NotNull
    private static String getHomePath(@NotNull AbstractUser abstractUser) {
        // since ACEs located in the subtree of a user are not supported by the converter,
        // there is no need to calculate a potential sub-path to be appended.
        return "home("+abstractUser.getId()+")";
    }

    private static void registerPrivileges(@NotNull PrivilegeDefinitions definitions, @NotNull Formatter formatter) {
        NameResolver nameResolver = new DefaultNamePathResolver(definitions.getNamespaceMapping());
        for (PrivilegeDefinition privilege : definitions.getDefinitions()) {
            try {
                RegisterPrivilege operation = new RegisterPrivilege(nameResolver.getJCRName(privilege.getName()), privilege.isAbstract(), getAggregatedNames(privilege, nameResolver));
                formatter.format("%s", operation.asRepoInitString());
            } catch (NamespaceException e) {
                throw new IllegalStateException(e);
            }
        }
    }

    @NotNull
    private static List<String> getAggregatedNames(@NotNull PrivilegeDefinition definition, @NotNull NameResolver nameResolver) {
        Set<Name> aggregatedNames = definition.getDeclaredAggregateNames();
        if (aggregatedNames.isEmpty()) {
            return Collections.emptyList();
        } else {
            return aggregatedNames.stream().map(name -> {
                try {
                    return nameResolver.getJCRName(name);
                } catch (NamespaceException e) {
                    throw new IllegalStateException(e);
                }
            }).collect(Collectors.toList());
        }
    }

    /**
     * Record the root path for all users/groups assuming that their common ancestor is a top-level node
     * 
     * @param userPath A user path
     */
    private void setUserRoot(@NotNull RepoPath userPath) {
        if (userRootPath == null) {
            userRootPath = new RepoPath(Text.getAbsoluteParent(userPath.toString(), 0));
        }
    }
    
    private boolean isBelowUserRoot(@NotNull RepoPath path) {
        return userRootPath != null && path.startsWith(userRootPath);
    }
}
