/*
 *  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.oak.security.authentication.ldap;


import static org.junit.Assume.assumeFalse;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.BindException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;

import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.ldap.InitialLdapContext;
import javax.naming.ldap.LdapContext;

import org.apache.directory.api.ldap.model.constants.SupportedSaslMechanisms;
import org.apache.directory.api.ldap.model.entry.DefaultEntry;
import org.apache.directory.api.ldap.model.entry.Entry;
import org.apache.directory.api.ldap.model.exception.LdapConfigurationException;
import org.apache.directory.api.ldap.model.exception.LdapException;
import org.apache.directory.api.ldap.model.ldif.LdifEntry;
import org.apache.directory.api.ldap.model.ldif.LdifReader;
import org.apache.directory.api.ldap.model.name.Dn;
import org.apache.directory.api.ldap.model.schema.SchemaManager;
import org.apache.directory.api.ldap.schema.manager.impl.DefaultSchemaManager;
import org.apache.directory.server.constants.ServerDNConstants;
import org.apache.directory.server.constants.SystemSchemaConstants;
import org.apache.directory.server.core.DefaultDirectoryService;
import org.apache.directory.server.core.api.CacheService;
import org.apache.directory.server.core.api.CoreSession;
import org.apache.directory.server.core.api.DirectoryService;
import org.apache.directory.server.core.api.InstanceLayout;
import org.apache.directory.server.core.api.schema.SchemaPartition;
import org.apache.directory.server.core.jndi.CoreContextFactory;
import org.apache.directory.server.core.partition.impl.avl.AvlPartition;
import org.apache.directory.server.core.shared.DefaultDnFactory;
import org.apache.directory.server.ldap.LdapServer;
import org.apache.directory.server.ldap.handlers.extended.StartTlsHandler;
import org.apache.directory.server.ldap.handlers.extended.StoredProcedureExtendedOperationHandler;
import org.apache.directory.server.ldap.handlers.sasl.MechanismHandler;
import org.apache.directory.server.ldap.handlers.sasl.cramMD5.CramMd5MechanismHandler;
import org.apache.directory.server.ldap.handlers.sasl.digestMD5.DigestMd5MechanismHandler;
import org.apache.directory.server.ldap.handlers.sasl.gssapi.GssapiMechanismHandler;
import org.apache.directory.server.ldap.handlers.sasl.ntlm.NtlmMechanismHandler;
import org.apache.directory.server.ldap.handlers.sasl.plain.PlainMechanismHandler;
import org.apache.directory.server.protocol.shared.transport.TcpTransport;
import org.apache.mina.util.AvailablePortFinder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A simple ldap test server
 */
public abstract class AbstractServer {

    public static final String  EXAMPLE_DN = "dc=example,dc=com";

    private static final Logger LOG = LoggerFactory.getLogger(AbstractServer.class);
    private static final List<LdifEntry> EMPTY_LIST = Collections.unmodifiableList(new ArrayList<LdifEntry>(0));
    private static final String CTX_FACTORY = "com.sun.jndi.ldap.LdapCtxFactory";

    /**
     * the context root for the rootDSE
     */
    protected CoreSession rootDSE;

    /**
     * flag whether to delete database files for each test or not
     */
    protected boolean doDelete = true;

    protected int port = -1;

    protected CacheService cacheService;

    protected DirectoryService directoryService;

    protected LdapServer ldapServer;


    /**
     * Loads an LDIF from an input stream and adds the entries it contains to
     * the server.  It appears as though the administrator added these entries
     * to the server.
     *
     * @param in            the input stream containing the LDIF entries to load
     * @return a list of entries added to the server in the order they were added
     * @throws NamingException of the load fails
     */
    public List<LdifEntry> loadLdif(InputStream in) throws Exception {
        if (in == null) {
            return EMPTY_LIST;
        }
        LdifReader ldifReader = new LdifReader(in);
        return loadLdif(ldifReader);
    }

    public List<LdifEntry> loadLdif(LdifReader ldifReader) throws Exception {
        List<LdifEntry> entries = new ArrayList<LdifEntry>();
        for (LdifEntry ldifEntry : ldifReader) {
            Dn dn = ldifEntry.getDn();
            if (ldifEntry.isEntry()) {
                org.apache.directory.api.ldap.model.entry.Entry items = ldifEntry.getEntry();
                rootDSE.add(new DefaultEntry(directoryService.getSchemaManager(), items));
                LOG.info("Added entry {}", dn);
                entries.add(ldifEntry);
            }
        }
        return entries;
    }

    /**
     * Inject an ldif String into the server. DN must be relative to the
     * root.
     *
     * @param ldif the entries to inject
     * @throws NamingException if the entries cannot be added
     */
    protected void addEntry(String ldif) throws Exception {
        ByteArrayInputStream in = new ByteArrayInputStream(ldif.getBytes("utf-8"));
        LdifReader reader = new LdifReader(in);
        loadLdif(reader);
    }

    /**
     * Common code to get an initial context via a simple bind to the
     * server over the wire using the SUN JNDI LDAP provider. Do not use
     * this method until after the setUp() method is called to start the
     * server otherwise it will fail.
     *
     * @return an LDAP context as the the administrator to the rootDSE
     * @throws NamingException if the server cannot be contacted
     */
    protected LdapContext getWiredContext() throws Exception {
        return getWiredContext(ServerDNConstants.ADMIN_SYSTEM_DN, "secret");
    }


    /**
     * Common code to get an initial context via a simple bind to the
     * server over the wire using the SUN JNDI LDAP provider. Do not use
     * this method until after the setUp() method is called to start the
     * server otherwise it will fail.
     *
     * @param bindPrincipalDn the DN of the principal to bind as
     * @param password        the password of the bind principal
     * @return an LDAP context as the the administrator to the rootDSE
     * @throws NamingException if the server cannot be contacted
     */
    protected LdapContext getWiredContext(String bindPrincipalDn, String password) throws Exception {
        Hashtable<String, String> env = new Hashtable<String, String>();
        env.put(Context.INITIAL_CONTEXT_FACTORY, CTX_FACTORY);
        env.put(Context.PROVIDER_URL, "ldap://localhost:" + port);
        env.put(Context.SECURITY_PRINCIPAL, bindPrincipalDn);
        env.put(Context.SECURITY_CREDENTIALS, password);
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        return new InitialLdapContext(env, null);
    }


    /**
     * Get's the initial context factory for the provider's ou=system context
     * root.
     */
    protected void setUp() throws Exception {
        File cwd = new File("target", "apacheds");
        doDelete(cwd);

        // setup directory service
        directoryService = new DefaultDirectoryService();
        directoryService.setShutdownHookEnabled(false);
        directoryService.setInstanceLayout(new InstanceLayout(cwd));

        cacheService = new CacheService();
        cacheService.initialize(directoryService.getInstanceLayout());

        SchemaManager schemaManager = new DefaultSchemaManager();
        directoryService.setSchemaManager(schemaManager);
        directoryService.setDnFactory(new DefaultDnFactory(directoryService.getSchemaManager(), cacheService.getCache("dnCache")));

        AvlPartition schLdifPart = new AvlPartition(directoryService.getSchemaManager(), directoryService.getDnFactory());
        schLdifPart.setId("schema");
        schLdifPart.setSuffixDn(directoryService.getDnFactory().create(ServerDNConstants.CN_SCHEMA_DN));
        SchemaPartition schPart = new SchemaPartition(directoryService.getSchemaManager());
        schPart.setWrappedPartition(schLdifPart);
        directoryService.setSchemaPartition(schPart);


        AvlPartition sysPart = new AvlPartition(directoryService.getSchemaManager(), directoryService.getDnFactory());
        sysPart.setId(SystemSchemaConstants.SCHEMA_NAME);
        sysPart.setSuffixDn(directoryService.getDnFactory().create(ServerDNConstants.SYSTEM_DN));
        directoryService.setSystemPartition(sysPart);

        AvlPartition examplePart = new AvlPartition(directoryService.getSchemaManager(), directoryService.getDnFactory());
        examplePart.setId("example");
        examplePart.setSuffixDn(directoryService.getDnFactory().create(EXAMPLE_DN));
        examplePart.setCacheService(cacheService);
        directoryService.addPartition(examplePart);

        // setup ldap server
        port = AvailablePortFinder.getNextAvailable(1024);
        ldapServer = new LdapServer();
        setupLdapServer();
        setupSaslMechanisms();

        directoryService.startup();
        setupExamplePartition();
        startLdapServer();
        setContexts(ServerDNConstants.ADMIN_SYSTEM_DN, "secret");
    }

    /**
     * Start the LDAP server assuming we can bind to the previously reserved port.
     * Given that there is a small race between when the port was reserved and when the
     * socket is actually bound this can still fail. For now we are ignoring this rare
     * case and skip the test. See OAK-5542.
     * TODO: OAK-5832: Make the LDAP server used in testing resilient against ports already in use
     * @throws Exception
     */
    private void startLdapServer() throws Exception {
        try {
            ldapServer.start();
        } catch (LdapConfigurationException e) {
            Throwable cause = e.getCause();
            assumeFalse("Ignoring this test as the server port is already in use (OAK-5542): " + cause,
                    cause instanceof BindException);
            throw e;
        }
    }

    protected void setupLdapServer() throws Exception {
        ldapServer.setTransports(new TcpTransport(port));
        ldapServer.setDirectoryService(directoryService);
        ldapServer.addExtendedOperationHandler(new StartTlsHandler());
        ldapServer.addExtendedOperationHandler(new StoredProcedureExtendedOperationHandler());
    }

    protected void setupExamplePartition() throws Exception {
        // setup acl to allow read all users
        // Inject the context entry for dc=example,dc=com partition if it does not already exist
        try {
            directoryService.getAdminSession().lookup(new Dn(EXAMPLE_DN));
        } catch (LdapException lnnfe) {
            Entry entry = directoryService.newEntry(new Dn(EXAMPLE_DN));
            entry.add("objectClass", "top", "domain", "extensibleObject");
            entry.add("dc", "example");
            entry.add("administrativeRole", "accessControlSpecificArea");
            directoryService.getAdminSession().add(entry);

            entry = directoryService.newEntry(new Dn("cn=enableSearchForAllUsers," + EXAMPLE_DN));
            entry.add("objectClass", "top", "subentry", "accessControlSubentry");
            entry.add("cn", "enableSearchForAllUsers");
            entry.add("subtreeSpecification", "{}");
            entry.add("prescriptiveACI",
                    "{ \n" +
                            "  identificationTag \"enableSearchForAllUsers\",\n" +
                            "  precedence 14,\n" +
                            "  authenticationLevel simple,\n" +
                            "  itemOrUserFirst userFirst: \n" +
                            "  { \n" +
                            "    userClasses { allUsers }, \n" +
                            "    userPermissions \n" +
                            "    { \n" +
                            "      {\n" +
                            "        protectedItems {entry, allUserAttributeTypesAndValues}, \n" +
                            "        grantsAndDenials { grantRead, grantReturnDN, grantBrowse } \n" +
                            "      }\n" +
                            "    } \n" +
                            "  } \n" +
                            "}");
            directoryService.getAdminSession().add(entry);
            directoryService.sync();
        }
    }

    public void setMaxSizeLimit(long maxSizeLimit) {
        ldapServer.setMaxSizeLimit(maxSizeLimit);
    }

    private void setupSaslMechanisms() {
        Map<String, MechanismHandler> mechanismHandlerMap = new HashMap<String, MechanismHandler>();

        mechanismHandlerMap.put(SupportedSaslMechanisms.PLAIN, new PlainMechanismHandler());

        CramMd5MechanismHandler cramMd5MechanismHandler = new CramMd5MechanismHandler();
        mechanismHandlerMap.put(SupportedSaslMechanisms.CRAM_MD5, cramMd5MechanismHandler);

        DigestMd5MechanismHandler digestMd5MechanismHandler = new DigestMd5MechanismHandler();
        mechanismHandlerMap.put(SupportedSaslMechanisms.DIGEST_MD5, digestMd5MechanismHandler);

        GssapiMechanismHandler gssapiMechanismHandler = new GssapiMechanismHandler();
        mechanismHandlerMap.put(SupportedSaslMechanisms.GSSAPI, gssapiMechanismHandler);

        NtlmMechanismHandler ntlmMechanismHandler = new NtlmMechanismHandler();
        // TODO - set some sort of default NtlmProvider implementation here
        // ntlmMechanismHandler.setNtlmProvider( provider );
        // TODO - or set FQCN of some sort of default NtlmProvider implementation here
        // ntlmMechanismHandler.setNtlmProviderFqcn( "com.foo.BarNtlmProvider" );
        mechanismHandlerMap.put(SupportedSaslMechanisms.NTLM, ntlmMechanismHandler);
        mechanismHandlerMap.put(SupportedSaslMechanisms.GSS_SPNEGO, ntlmMechanismHandler);

        ldapServer.setSaslMechanismHandlers(mechanismHandlerMap);
    }


    /**
     * Deletes the Eve working directory.
     *
     * @param wkdir the directory to delete
     * @throws IOException if the directory cannot be deleted
     */
    protected void doDelete(File wkdir) throws IOException {
        if (doDelete) {
            if (wkdir.exists()) {
                deleteDirectory(wkdir);
            }

            if (wkdir.exists()) {
                throw new IOException("Failed to delete: " + wkdir);
            }
        }
    }

//-----------------------------------------------------------------------------

    public static void deleteDirectory(File directory) throws IOException {
        if (directory.exists()) {
            if (!isSymlink(directory)) {
                cleanDirectory(directory);
            }

            if (!directory.delete()) {
                String message = "Unable to delete directory " + directory + ".";
                throw new IOException(message);
            }
        }
    }

    public static boolean isSymlink(File file) throws IOException {
        if (file == null) {
            throw new NullPointerException("File must not be null");
        } else {
            return Files.isSymbolicLink(file.toPath());
        }
    }

    public static void cleanDirectory(File directory) throws IOException {
        File[] files = verifiedListFiles(directory);
        IOException exception = null;
        File[] var3 = files;
        int var4 = files.length;

        for(int var5 = 0; var5 < var4; ++var5) {
            File file = var3[var5];

            try {
                forceDelete(file);
            } catch (IOException var8) {
                exception = var8;
            }
        }

        if (null != exception) {
            throw exception;
        }
    }

    private static File[] verifiedListFiles(File directory) throws IOException {
        String message;
        if (!directory.exists()) {
            message = directory + " does not exist";
            throw new IllegalArgumentException(message);
        } else if (!directory.isDirectory()) {
            message = directory + " is not a directory";
            throw new IllegalArgumentException(message);
        } else {
            File[] files = directory.listFiles();
            if (files == null) {
                throw new IOException("Failed to list contents of " + directory);
            } else {
                return files;
            }
        }
    }

    public static void forceDelete(File file) throws IOException {
        if (file.isDirectory()) {
            deleteDirectory(file);
        } else {
            boolean filePresent = file.exists();
            if (!file.delete()) {
                if (!filePresent) {
                    throw new FileNotFoundException("File does not exist: " + file);
                }

                String message = "Unable to delete file: " + file;
                throw new IOException(message);
            }
        }
    }

//-----------------------------------------------------------------------------
    /**
     * Sets the contexts for this base class.  Values of user and password used to
     * set the respective JNDI properties.  These values can be overriden by the
     * overrides properties.
     *
     * @param user   the username for authenticating as this user
     * @param passwd the password of the user
     * @throws NamingException if there is a failure of any kind
     */
    protected void setContexts(String user, String passwd) throws Exception {
        Hashtable<String, Object> env = new Hashtable<String, Object>();
        env.put(DirectoryService.JNDI_KEY, directoryService);
        env.put(Context.SECURITY_PRINCIPAL, user);
        env.put(Context.SECURITY_CREDENTIALS, passwd);
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.INITIAL_CONTEXT_FACTORY, CoreContextFactory.class.getName());
        setContexts(env);
    }

    /**
     * Sets the contexts of this class taking into account the extras and overrides
     * properties.
     *
     * @param env an environment to use while setting up the system root.
     * @throws NamingException if there is a failure of any kind
     */
    protected void setContexts(Hashtable<String, Object> env) throws Exception {
        Hashtable<String, Object> envFinal = new Hashtable<String, Object>(env);
        envFinal.put(Context.PROVIDER_URL, "");
        rootDSE = directoryService.getAdminSession();
    }

    /**
     * Sets the system context root to null.
     */
    protected void tearDown() throws Exception {
        if (ldapServer != null) {
            ldapServer.stop();
        }
        try {
            directoryService.shutdown();
        } catch (Exception e) {
            // ignore
        }
        if (cacheService != null) {
            cacheService.destroy();
        }
    }
}
