/*
 * JBoss, Home of Professional Open Source
 * Copyright 2014 Red Hat, Inc., and individual contributors
 * as indicated by the @author tags.
 *
 * Licensed 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.wildfly.security.auth.realm.ldap;

import static org.wildfly.common.Assert.assertNotNull;
import static org.wildfly.security.auth.realm.ldap.ElytronMessages.log;
import static org.wildfly.security.provider.util.ProviderUtil.INSTALLED_PROVIDERS;

import org.wildfly.common.Assert;
import org.wildfly.common.function.ExceptionSupplier;
import org.wildfly.security.auth.realm.ldap.LdapSecurityRealm.IdentityMapping;
import org.wildfly.security.auth.server.ModifiableSecurityRealm;
import org.wildfly.security.auth.server.NameRewriter;
import org.wildfly.security.password.spec.Encoding;

import javax.naming.NamingException;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.ldap.LdapName;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.Provider;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Supplier;

/**
 * Builder for the security realm implementation backed by LDAP.
 *
 * @author <a href="mailto:darran.lofthouse@jboss.com">Darran Lofthouse</a>
 */
public class LdapSecurityRealmBuilder {

    /*
     *  The LDAP security realm constructed by this builder expects no further modifications to the
     *  Collections it is passed.
     *
     *  This is the reason this builder and all child builders are implemented to prevent subsequent
     *  modification after the build is complete.
     */

    private static final int DEFAULT_SEARCH_TIME_LIMIT = 10000;

    private boolean built = false;
    private Supplier<Provider[]> providers = INSTALLED_PROVIDERS;
    private ExceptionSupplier<DirContext, NamingException> dirContextSupplier;
    private NameRewriter nameRewriter = NameRewriter.IDENTITY_REWRITER;
    private IdentityMapping identityMapping;
    private int pageSize = 50;

    private List<CredentialLoader> credentialLoaders = new ArrayList<>();
    private List<CredentialPersister> credentialPersisters = new ArrayList<>();
    private List<EvidenceVerifier> evidenceVerifiers = new ArrayList<>();
    private Charset hashCharset = StandardCharsets.UTF_8;
    private Encoding hashEncoding = Encoding.BASE64;

    private LdapSecurityRealmBuilder() {
    }

    /**
     * Construct a new instance.
     *
     * @return the new builder instance
     */
    public static LdapSecurityRealmBuilder builder() {
        return new LdapSecurityRealmBuilder();
    }

    /**
     * The the Provider[] supplier.
     *
     * @param providers the supplier of Providers to be used by the realm
     * @return this builder
     */
    public LdapSecurityRealmBuilder setProviders(Supplier<Provider[]> providers) {
        assertNotBuilt();
        this.providers = providers;

        return this;
    }

    /**
     * Set the directory context supplier.
     *
     * @param dirContextSupplier the directory context supplier
     * @return this builder
     */
    public LdapSecurityRealmBuilder setDirContextSupplier(final ExceptionSupplier<DirContext, NamingException> dirContextSupplier) {
        assertNotBuilt();

        this.dirContextSupplier = dirContextSupplier;

        return this;
    }

    /**
     * Add a name rewriter to this builder.
     *
     * @param nameRewriter the name rewriter
     * @return this builder
     */
    public LdapSecurityRealmBuilder setNameRewriter(final NameRewriter nameRewriter) {
        Assert.checkNotNullParam("nameRewriter", nameRewriter);
        assertNotBuilt();

        this.nameRewriter = nameRewriter;

        return this;
    }

    /**
     * Set size of page for realm iterating
     *
     * @param pageSize size of page
     * @return this builder
     */
    public LdapSecurityRealmBuilder setPageSize(final int pageSize) {
        this.pageSize = pageSize;

        return this;
    }

    public IdentityMappingBuilder identityMapping() {
        assertNotBuilt();

        return new IdentityMappingBuilder();
    }

    /**
     * Add a principal mapping to this builder.
     *
     * @return the builder for the principal mapping
     */
    LdapSecurityRealmBuilder setIdentityMapping(IdentityMapping principalMapping) {
        this.identityMapping = principalMapping;

        return this;
    }

    /**
     * Set the character set to use when converting the password string
     * to a byte array. Set to UTF-8 by default.
     * @param hashCharset the name of the character set (must not be {@code null}).
     *
     * @return this builder
     */
    public LdapSecurityRealmBuilder setHashCharset(Charset hashCharset) {
        assertNotNull(hashCharset);
        this.hashCharset = hashCharset;

        return this;
    }

    /**
     *  Set the string format for the password in the properties file if they are not
     *  stored in plain text. Set to base64 by default.
     * @param hashEncoding specifies the string format for the hashed password
     *
     * @return this builder
     */
    public LdapSecurityRealmBuilder setHashEncoding(Encoding hashEncoding) {
        assertNotNull(hashEncoding);
        this.hashEncoding = hashEncoding;

        return this;
    }

    public UserPasswordCredentialLoaderBuilder userPasswordCredentialLoader() {
        assertNotBuilt();

        return new UserPasswordCredentialLoaderBuilder();
    }

    public OtpCredentialLoaderBuilder otpCredentialLoader() {
        assertNotBuilt();

        return new OtpCredentialLoaderBuilder();
    }

    public X509EvidenceVerifierBuilder x509EvidenceVerifier() {
        assertNotBuilt();

        return new X509EvidenceVerifierBuilder();
    }

    LdapSecurityRealmBuilder addCredentialLoader(CredentialLoader credentialLoader) {
        credentialLoaders.add(credentialLoader);

        return this;
    }

    LdapSecurityRealmBuilder addCredentialPersister(CredentialPersister credentialPersister) {
        credentialPersisters.add(credentialPersister);

        return this;
    }

    LdapSecurityRealmBuilder addEvidenceVerifier(EvidenceVerifier evidenceVerifier) {
        evidenceVerifiers.add(evidenceVerifier);

        return this;
    }

    public LdapSecurityRealmBuilder addDirectEvidenceVerification() {
        return addDirectEvidenceVerification(false);
    }

    public LdapSecurityRealmBuilder addDirectEvidenceVerification(boolean allowBlankPassword) {
        assertNotBuilt();

        return addEvidenceVerifier(new DirectEvidenceVerifier(allowBlankPassword));
    }

    /**
     * Build this realm.
     *
     * @return the built realm
     */
    public ModifiableSecurityRealm build() {
        assertNotBuilt();
        if (dirContextSupplier == null) {
            throw log.noDirContextSupplierSet();
        }
        if (identityMapping == null) {
            throw log.noPrincipalMappingDefinition();
        }

        built = true;
        return new LdapSecurityRealm(providers, dirContextSupplier, nameRewriter,
                identityMapping, credentialLoaders, credentialPersisters, evidenceVerifiers,
                pageSize, hashCharset, hashEncoding);
    }

    private void assertNotBuilt() {
        if (built) {
            throw log.builderAlreadyBuilt();
        }
    }

    /**
     * A builder for a principal mapping.
     */
    public class IdentityMappingBuilder {

        private boolean built = false;

        private String searchDn = null;
        private boolean searchRecursive = false;
        private String nameAttribute;
        private int searchTimeLimit = DEFAULT_SEARCH_TIME_LIMIT;
        private List<AttributeMapping> attributes = new ArrayList<>();
        private LdapName newIdentityParent = null;
        private Attributes newIdentityAttributes = null;
        private String filterName;
        private String iteratorFilter;

        /**
         * <p>Set the name of the context to be used when executing queries.
         *
         * <p>This option is specially useful when authenticating users based on names that don't use a X.500 format such as <em>plainUser</em>.
         * In this case, you must also provide {@link #setRdnIdentifier(String)} with the attribute name that contains the user name.</p>
         *
         * <p>If the names used to authenticate users are based on the X.500 format, this configuration can be suppressed.
         *
         * <p>Please note that by using this option the realm is able to authenticate users based on their simple or X.500 names.
         *
         * @param searchDn the name of the context to search
         * @return this builder
         */
        public IdentityMappingBuilder setSearchDn(final String searchDn) {
            assertNotBuilt();

            this.searchDn = searchDn;
            return this;
        }

        /**
         * Indicate if queries are searchRecursive, searching the entire subtree rooted at the name specified in {@link #setSearchDn(String)}.
         * Otherwise search one level of the named context.
         *
         * @return this builder
         */
        public IdentityMappingBuilder searchRecursive() {
            assertNotBuilt();

            this.searchRecursive = true;
            return this;
        }

        /**
         * Sets the time limit of LDAP search in milliseconds.
         *
         * @param limit the limit in milliseconds. Defaults to {@value #DEFAULT_SEARCH_TIME_LIMIT} milliseconds.
         * @return this builder
         */
        public IdentityMappingBuilder setSearchTimeLimit(int limit) {
            assertNotBuilt();

            this.searchTimeLimit = limit;
            return this;
        }

        /**
         * Set the name of the attribute in LDAP that holds the user name and will appear in path of new entries.
         *
         * @param nameAttribute the name attribute
         * @return this builder
         */
        public IdentityMappingBuilder setRdnIdentifier(final String nameAttribute) {
            assertNotBuilt();

            this.nameAttribute = nameAttribute;
            return this;
        }

        public IdentityMappingBuilder setNewIdentityParent(LdapName newIdentityParent) {
            assertNotBuilt();

            this.newIdentityParent = newIdentityParent;
            return this;
        }

        public IdentityMappingBuilder setNewIdentityAttributes(Attributes newIdentityAttributes) {
            assertNotBuilt();

            this.newIdentityAttributes = newIdentityAttributes;
            return this;
        }

        public IdentityMappingBuilder setFilterName(String filterName) {
            assertNotBuilt();

            this.filterName = filterName;
            return this;
        }

        public IdentityMappingBuilder setIteratorFilter(String iteratorFilter) {
            assertNotBuilt();

            this.iteratorFilter = iteratorFilter;
            return this;
        }

        /**
         * Define an attribute mapping configuration.
         *
         * @param attributes one or more {@link AttributeMapping} configuration
         * @return this builder
         */
        public IdentityMappingBuilder map(AttributeMapping... attributes) {
            assertNotBuilt();

            this.attributes.addAll(Arrays.asList(attributes));
            return this;
        }

        public LdapSecurityRealmBuilder build() {
            assertNotBuilt();
            built = true;

            if (filterName == null) filterName = String.format("(%s={0})", nameAttribute);

            return LdapSecurityRealmBuilder.this.setIdentityMapping(new IdentityMapping(
                    searchDn, searchRecursive, searchTimeLimit, nameAttribute, attributes,
                    newIdentityParent, newIdentityAttributes, filterName, iteratorFilter));
        }

        private void assertNotBuilt() {
            if (built) {
                throw log.builderAlreadyBuilt();
            }

            LdapSecurityRealmBuilder.this.assertNotBuilt();
        }

    }

    public class UserPasswordCredentialLoaderBuilder {

        private boolean built = false;

        private String userPasswordAttribute = UserPasswordCredentialLoader.DEFAULT_USER_PASSWORD_ATTRIBUTE_NAME;
        private boolean enablePersistence = false;
        private boolean enableVerification = true;

        /**
         * Set the name of the attribute within the LDAP entry that should be queries to load the credential.
         *
         * @param userPasswordAttribute the name of the attribute within the LDAP entry that should be queries to load the credential.
         * @return the {@link UserPasswordCredentialLoaderBuilder} to allow chaining of calls.
         */
        public UserPasswordCredentialLoaderBuilder setUserPasswordAttribute(final String userPasswordAttribute) {
            assertNotBuilt();
            this.userPasswordAttribute = userPasswordAttribute;

            return this;
        }

        /**
         * Enable persistence for the {@link UserPasswordCredentialLoader} being defined.
         *
         * @return the {@link UserPasswordCredentialLoaderBuilder} to allow chaining of calls.
         */
        public UserPasswordCredentialLoaderBuilder enablePersistence() {
            assertNotBuilt();
            enablePersistence = true;

            return this;
        }

        /**
         * By default if we can obtain a credential we support verification against it, this disables it.
         *
         * @return the {@link UserPasswordCredentialLoaderBuilder} to allow chaining of calls.
         */
        public UserPasswordCredentialLoaderBuilder disableVerification() {
            assertNotBuilt();
            enableVerification = false;

            return this;
        }

        public LdapSecurityRealmBuilder build() {
            assertNotBuilt();
            built = true;

            UserPasswordCredentialLoader upcl = new UserPasswordCredentialLoader(userPasswordAttribute);
            LdapSecurityRealmBuilder.this.addCredentialLoader(upcl);
            if (enablePersistence) LdapSecurityRealmBuilder.this.addCredentialPersister(upcl);
            if (enableVerification) LdapSecurityRealmBuilder.this.addEvidenceVerifier(upcl.toEvidenceVerifier());

            return LdapSecurityRealmBuilder.this;
        }


        private void assertNotBuilt() {
            if (built) {
                throw log.builderAlreadyBuilt();
            }

            LdapSecurityRealmBuilder.this.assertNotBuilt();
        }
    }

    public class OtpCredentialLoaderBuilder {

        private boolean built = false;

        private String otpAlgorithmAttribute = null;
        private String otpHashAttribute = null;
        private String otpSeedAttribute = null;
        private String otpSequenceAttribute = null;

        public OtpCredentialLoaderBuilder setOtpAlgorithmAttribute(final String otpAlgorithmAttribute) {
            assertNotBuilt();
            this.otpAlgorithmAttribute = otpAlgorithmAttribute;

            return this;
        }

        public OtpCredentialLoaderBuilder setOtpHashAttribute(final String otpHashAttribute) {
            assertNotBuilt();
            this.otpHashAttribute = otpHashAttribute;

            return this;
        }

        public OtpCredentialLoaderBuilder setOtpSeedAttribute(final String otpSeedAttribute) {
            assertNotBuilt();
            this.otpSeedAttribute = otpSeedAttribute;

            return this;
        }

        public OtpCredentialLoaderBuilder setOtpSequenceAttribute(final String otpSequenceAttribute) {
            assertNotBuilt();
            this.otpSequenceAttribute = otpSequenceAttribute;

            return this;
        }

        public LdapSecurityRealmBuilder build() {
            assertNotBuilt();
            built = true;

            OtpCredentialLoader ocl = new OtpCredentialLoader(otpAlgorithmAttribute, otpHashAttribute, otpSeedAttribute, otpSequenceAttribute);
            LdapSecurityRealmBuilder.this.addCredentialLoader(ocl);
            LdapSecurityRealmBuilder.this.addCredentialPersister(ocl);

            return LdapSecurityRealmBuilder.this;
        }

        private void assertNotBuilt() {
            if (built) {
                throw log.builderAlreadyBuilt();
            }

            LdapSecurityRealmBuilder.this.assertNotBuilt();
        }
    }

    public class X509EvidenceVerifierBuilder {

        private boolean built = false;

        private List<X509EvidenceVerifier.CertificateVerifier> certificateVerifiers = new ArrayList<>();

        public X509EvidenceVerifierBuilder addSerialNumberCertificateVerifier(final String ldapAttribute) {
            assertNotBuilt();
            certificateVerifiers.add(new X509EvidenceVerifier.SerialNumberCertificateVerifier(ldapAttribute));

            return this;
        }

        public X509EvidenceVerifierBuilder addSubjectDnCertificateVerifier(final String ldapAttribute) {
            assertNotBuilt();
            certificateVerifiers.add(new X509EvidenceVerifier.SubjectDnCertificateVerifier(ldapAttribute));

            return this;
        }

        public X509EvidenceVerifierBuilder addDigestCertificateVerifier(final String ldapAttribute, final String algorithm) {
            assertNotBuilt();
            certificateVerifiers.add(new X509EvidenceVerifier.DigestCertificateVerifier(ldapAttribute, algorithm));

            return this;
        }

        public X509EvidenceVerifierBuilder addEncodedCertificateVerifier(final String ldapAttribute) {
            assertNotBuilt();
            certificateVerifiers.add(new X509EvidenceVerifier.EncodedCertificateVerifier(ldapAttribute));

            return this;
        }

        public LdapSecurityRealmBuilder build() {
            assertNotBuilt();
            Assert.checkNotEmptyParam("certificateVerifiers", certificateVerifiers);
            built = true;

            addEvidenceVerifier(new X509EvidenceVerifier(certificateVerifiers));

            return LdapSecurityRealmBuilder.this;
        }

        private void assertNotBuilt() {
            if (built) {
                throw log.builderAlreadyBuilt();
            }

            LdapSecurityRealmBuilder.this.assertNotBuilt();
        }
    }
}
