/*
 *   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.directory.server.replication;


import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.directory.api.ldap.codec.api.LdapApiService;
import org.apache.directory.api.ldap.extras.controls.syncrepl_impl.SyncDoneValueFactory;
import org.apache.directory.api.ldap.extras.controls.syncrepl_impl.SyncRequestValueFactory;
import org.apache.directory.api.ldap.extras.controls.syncrepl_impl.SyncStateValueFactory;
import org.apache.directory.api.ldap.model.constants.SchemaConstants;
import org.apache.directory.api.ldap.model.csn.Csn;
import org.apache.directory.api.ldap.model.cursor.Cursor;
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.message.ModifyRequest;
import org.apache.directory.api.ldap.model.message.ModifyRequestImpl;
import org.apache.directory.api.ldap.model.message.SearchRequest;
import org.apache.directory.api.ldap.model.message.SearchRequestImpl;
import org.apache.directory.api.ldap.model.message.SearchScope;
import org.apache.directory.api.ldap.model.name.Dn;
import org.apache.directory.api.ldap.model.name.Rdn;
import org.apache.directory.api.ldap.model.schema.SchemaManager;
import org.apache.directory.server.annotations.CreateConsumer;
import org.apache.directory.server.annotations.CreateLdapServer;
import org.apache.directory.server.annotations.CreateTransport;
import org.apache.directory.server.core.annotations.ContextEntry;
import org.apache.directory.server.core.annotations.CreateDS;
import org.apache.directory.server.core.annotations.CreateIndex;
import org.apache.directory.server.core.annotations.CreatePartition;
import org.apache.directory.server.core.api.CoreSession;
import org.apache.directory.server.core.api.DirectoryService;
import org.apache.directory.server.core.factory.DSAnnotationProcessor;
import org.apache.directory.server.core.integ.ApacheDSTestExtension;
import org.apache.directory.server.core.security.TlsKeyGenerator;
import org.apache.directory.server.factory.ServerAnnotationProcessor;
import org.apache.directory.server.ldap.LdapServer;
import org.apache.directory.server.ldap.replication.consumer.ReplicationConsumer;
import org.apache.directory.server.ldap.replication.consumer.ReplicationConsumerImpl;
import org.apache.directory.server.ldap.replication.provider.SyncReplRequestHandler;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;


/**
 * Tests for replication subsystem in client-server mode.
 *
 * @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
 */
public class ClientServerReplicationIT
{
    private static LdapServer providerServer;

    private static LdapServer consumerServer;

    private static SchemaManager schemaManager;

    private static CoreSession providerSession;

    private static CoreSession consumerSession;

    private static AtomicInteger entryCount = new AtomicInteger();


    @BeforeAll
    public static void setUp() throws Exception
    {
        Class.forName( ApacheDSTestExtension.class.getName() );
        CountDownLatch counter = new CountDownLatch( 2 );

        startProvider( counter );
        startConsumer( counter );

        // Wait for the two servers to be up and running
        counter.await();
    }


    @AfterAll
    public static void tearDown() throws Exception
    {
        consumerServer.stop();
        consumerServer.getDirectoryService().shutdown();
        providerServer.stop();
        providerServer.getDirectoryService().shutdown();
    }


    private void dump( CoreSession session, Dn entryDn )
    {
        try
        {
            SearchRequest searchRequest = new SearchRequestImpl();

            searchRequest.setBase( new Dn( schemaManager, "dc=example,dc=com" ) );
            searchRequest.setFilter( "(objectClass=*)" );
            searchRequest.setScope( SearchScope.SUBTREE );
            searchRequest.addAttributes( "entryUuid" );

            System.out.println( "-----------> Dumping the server <-----------" );
            System.out.println( "-----------> Looking for " + entryDn.getNormName() + " <-----------" );

            Cursor<Entry> cursor = session.search( searchRequest );

            while ( cursor.next() )
            {
                Entry entry = cursor.get();

                if ( entry.getDn().equals( entryDn ) )
                {
                    System.out.println( "The searched entry exists !!!" );
                    System.out.println( "found Entry " + entry.getDn().getNormName() + " exists, entrtyUuid = "
                        + entry.get( "entryUuid" ) );
                    continue;
                }

                System.out.println( "Entry " + entry.getDn().getNormName() + " exists, entrtyUuid = "
                    + entry.get( "entryUuid" ) );
            }

            cursor.close();

            System.out.println( "-----------> Dump done <-----------" );
        }
        catch ( Exception le )
        {
            // Do nothing
            le.printStackTrace();
        }
    }


    /**
     * Check that the entry was replicated to the target server. That is the case when the entry exists on the target
     * server and its entryCSN is greater than or equal compared to the source entry. We wait up to 10 seconds, 
     * by 100ms steps, until either the entry s found, or we have exhausted the 10 seconds delay.
     */
    private boolean checkEntryReplicated( Dn entryDn ) throws Exception
    {
        return checkEntryReplicated( entryDn, false );
    }


    /**
     * Check that the entry was replicated to the target server. That is the case when the entry exists on the target
     * server and its entryCSN is greater than or equal compared to the source entry. We wait up to 10 seconds, 
     * by 100ms steps, until either the entry s found, or we have exhausted the 10 seconds delay.
     */
    private boolean checkEntryReplicated( Dn entryDn, boolean print ) throws Exception
    {
        boolean replicated = false;

        for ( int i = 0; i < 100; i++ )
        {
            Thread.sleep( 50 );

            if ( consumerSession.exists( entryDn ) )
            {
                if ( print )
                {
                    System.out.println( entryDn.getName() + " exists " );
                }

                Entry providerEntry = providerSession.lookup( entryDn, "*", "+" );
                Entry consumerEntry = consumerSession.lookup( entryDn, "*", "+" );
                Csn providerCSN = new Csn( providerEntry.get( SchemaConstants.ENTRY_CSN_AT ).getString() );
                Csn consumerCSN = new Csn( consumerEntry.get( SchemaConstants.ENTRY_CSN_AT ).getString() );

                if ( consumerCSN.compareTo( providerCSN ) >= 0 )
                {
                    if ( print )
                    {
                        System.out.println( entryDn.getName() + " replicated " );
                    }

                    replicated = true;
                    break;
                }
            }

            Thread.sleep( 50 );
        }

        if ( replicated == false )
        {
            dump( consumerSession, entryDn );
        }

        return replicated;
    }


    /**
     * Check that the entry exists and has been deleted in the target server. We wait up to 10 seconds, by
     * 100ms steps, until either the entry is deleted, or we have exhausted the 10 seconds delay,
     * or the entry was never found to start with.
     */
    private boolean checkEntryDeletion( CoreSession session, Dn entryDn ) throws Exception
    {
        boolean exists = session.exists( entryDn );

        if ( !exists )
        {
            return true;
        }

        for ( int i = 0; i < 100; i++ )
        {
            Thread.sleep( 100 );

            exists = session.exists( entryDn );

            if ( !exists )
            {
                return true;
            }

            Thread.sleep( 100 );
        }

        dump( session, entryDn );

        return false;
    }


    @Test
    public void testModify() throws Exception
    {
        Entry provUser = createEntry();

        // precondition: entry does not exist
        assertFalse( providerSession.exists( provUser.getDn() ) );
        assertFalse( consumerSession.exists( provUser.getDn() ) );

        // add the entry and check it is replicated
        //System.out.println( ">--------------------------------------- Adding " + provUser );
        providerSession.add( provUser );
        //System.out.println( ">--------------------------------------- Added " );

        assertTrue( providerSession.exists( provUser.getDn() ) );
        assertTrue( checkEntryReplicated( provUser.getDn() ) );

        // modify the entry and check it is replicated
        ModifyRequest modReq = new ModifyRequestImpl();
        modReq.setName( provUser.getDn() );
        modReq.add( "userPassword", "secret" );

        // to test replication of binary data
        modReq.add( "objectClass", "inetOrgPerson" );
        modReq.add( "uid", "uid" );
        TlsKeyGenerator.addKeyPair( provUser );
        modReq.add( "userCertificate", provUser.get( "userCertificate" ).getBytes() );
        
        //System.out.println( ">--------------------------------------- Modifying " + modReq );
        providerSession.modify( modReq );
        //System.out.println( ">--------------------------------------- Modified " );

        assertTrue( checkEntryReplicated( provUser.getDn() ) );
        compareEntries( provUser.getDn() );
    }


    @Test
    public void testModDn() throws Exception
    {
        Entry provUser = createEntry();
        Dn userDn = provUser.getDn();

        Thread.sleep( 500 );

        assertFalse( consumerSession.exists( userDn ) );

        // Add entry "cn=entryN,dc=example,dc=com" and check it is replicated
        providerSession.add( provUser );

        assertTrue( checkEntryReplicated( userDn ) );

        // Add container for users "ou=users,dc=example,dc=com" and check it is replicated
        Dn usersContainerDn = new Dn( schemaManager, "ou=users,dc=example,dc=com" );

        Entry userContainer = new DefaultEntry( schemaManager, usersContainerDn,
            "objectClass: organizationalUnit",
            "ou: users" );

        providerSession.add( userContainer );

        assertTrue( checkEntryReplicated( usersContainerDn ) );
        compareEntries( userContainer.getDn() );

        // Move entry "cn=entryN,dc=example,dc=com" to "ou=users,dc=example,dc=com" and check it is replicated
        providerSession.move( userDn, usersContainerDn );

        // The moved entry : "cn=entryN,ou=users,dc=example,dc=com"
        Dn movedEntryDn = usersContainerDn.add( userDn.getRdn() );

        assertTrue( checkEntryReplicated( movedEntryDn ) );
        compareEntries( movedEntryDn );

        // Rename "cn=entryN,ou=users,dc=example,dc=com" to "cn=entryNrenamed,ou=users,dc=example,dc=com" and check it is replicated
        Rdn newName = new Rdn( schemaManager, movedEntryDn.getRdn().getName() + "renamed" );
        providerSession.rename( movedEntryDn, newName, true );

        Dn renamedEntryDn = usersContainerDn.add( newName );

        assertTrue( checkEntryReplicated( renamedEntryDn ) );
        compareEntries( renamedEntryDn );

        // now move and rename
        Dn newParent = usersContainerDn.getParent();

        newName = new Rdn( schemaManager, renamedEntryDn.getRdn().getName() + "MovedAndRenamed" );

        // Move and rename "cn=entryNrenamed,ou=users,dc=example,dc=com" to
        // "cn=entryNMovedAndRenamed,dc=example,dc=com"  and check it is replicated
        providerSession.moveAndRename( renamedEntryDn, newParent, newName, false );

        Dn movedAndRenamedEntryDn = newParent.add( newName );

        assertTrue( checkEntryReplicated( movedAndRenamedEntryDn ) );
        compareEntries( movedAndRenamedEntryDn );

        // cleanup
        providerSession.delete( usersContainerDn );
    }


    @Test
    @Disabled("Run this test alone, otherwise it conflicts with moddn")
    public void testModDnLoop() throws Exception
    {
        for ( int i = 0; i < 10; i++ )
        {
            System.out.println( ">>>>>> loop " + ( i + 1 ) + " <<<<<<" );
            Entry newuser = createEntry();

            assertFalse( consumerSession.exists( newuser.getDn() ) );

            // Add entry : "cn=entryN,dc=example,dc=com"
            providerSession.add( newuser ); // 1

            Dn usersContainer = new Dn( schemaManager, "ou=users,dc=example,dc=com" );

            DefaultEntry usersEntry = new DefaultEntry( schemaManager, usersContainer,
                "objectClass: organizationalUnit",
                "ou: users" );

            // Add entry "ou=users,dc=example,dc=com"
            providerSession.add( usersEntry ); // 2

            assertTrue( checkEntryReplicated( usersContainer ) );
            compareEntries( usersEntry.getDn() );

            // Move entry "cn=entryN,dc=example,dc=com" to "ou=users,dc=example,dc=com"
            Dn userDn = newuser.getDn();
            providerSession.move( userDn, usersContainer );

            // The moved entry : "cn=entryN,ou=users,dc=example,dc=com"
            Dn movedEntryDn = usersContainer.add( userDn.getRdn() );

            assertTrue( checkEntryReplicated( movedEntryDn ) );
            compareEntries( movedEntryDn );

            Rdn newName = new Rdn( schemaManager, movedEntryDn.getRdn().getName() + "renamed" );

            // Rename "cn=entryN,ou=users,dc=example,dc=com" to "cn=entryNrenamed,ou=users,dc=example,dc=com"
            providerSession.rename( movedEntryDn, newName, true );

            Dn renamedEntryDn = usersContainer.add( newName );

            assertTrue( checkEntryReplicated( renamedEntryDn ) );
            compareEntries( renamedEntryDn );

            // now move and rename
            Dn newParent = usersContainer.getParent();

            newName = new Rdn( schemaManager, renamedEntryDn.getRdn().getName() + "MovedAndRenamed" );

            // Move and rename "cn=entryNrenamed,ou=users,dc=example,dc=com" to
            // "cn=entryNMovedAndRenamed,dc=example,dc=com"
            providerSession.moveAndRename( renamedEntryDn, newParent, newName, false ); //4

            Dn movedAndRenamedEntry = newParent.add( newName );

            assertTrue( checkEntryReplicated( movedAndRenamedEntry ) );
            compareEntries( movedAndRenamedEntry );

            // Ok, no failure, revert everything
            providerSession.delete( movedAndRenamedEntry );
            providerSession.delete( usersContainer );
        }
    }


    /**
     * Test the replication of a deleted entry
     */
    @Test
    public void testDelete() throws Exception
    {
        Entry provUser = createEntry();

        providerSession.add( provUser );

        assertTrue( checkEntryReplicated( provUser.getDn() ) );
        compareEntries( provUser.getDn() );

        assertTrue( providerSession.exists( provUser.getDn() ) );
        assertTrue( consumerSession.exists( provUser.getDn() ) );

        providerSession.delete( provUser.getDn() );

        assertTrue( checkEntryDeletion( consumerSession, provUser.getDn() ) );
        assertFalse( providerSession.exists( provUser.getDn() ) );
    }


    private Entry restartConsumer( Entry provUser ) throws Exception
    {
        //System.out.println( "------------------------------------- Stop consumer" );
        // Now stop the consumer
        consumerServer.stop();

        // And delete the entry in the provider
        Dn deletedUserDn = provUser.getDn();

        providerSession.delete( deletedUserDn );

        // Create a new entry
        provUser = createEntry();
        Dn addedUserDn = provUser.getDn();

        providerSession.add( provUser );

        // let the provider log the events before the consumer sends a request
        // we are dealing with fraction of seconds cause of the programmatic simulation
        // it is impossible in the real world scenario
        Thread.sleep( 1000 );

        // Restart the consumer
        //System.out.println( "------------------------------------- Start consumer" );
        consumerServer.start();

        assertTrue( checkEntryDeletion( consumerSession, deletedUserDn ) );

        assertTrue( checkEntryReplicated( addedUserDn ) );
        compareEntries( addedUserDn );

        return provUser;
    }


    @Test
    public void testRebootConsumer() throws Exception
    {
        Entry provUser = createEntry();

        assertFalse( providerSession.exists( provUser.getDn() ) );
        assertFalse( consumerSession.exists( provUser.getDn() ) );

        providerSession.add( provUser );

        assertTrue( checkEntryReplicated( provUser.getDn() ) );
        compareEntries( provUser.getDn() );

        assertTrue( providerSession.exists( provUser.getDn() ) );
        assertTrue( consumerSession.exists( provUser.getDn() ) );

        for ( int i = 0; i < 10; i++ )
        {
            provUser = restartConsumer( provUser );
        }
    }
    
    
    private void compareEntries( Dn dn ) throws Exception
    {
        String[] searchAttributes = new String[]
            {
                SchemaConstants.ALL_USER_ATTRIBUTES,
                SchemaConstants.ENTRY_UUID_AT
        };

        Entry providerEntry = providerSession.lookup( dn, searchAttributes );
        Entry consumerEntry = consumerSession.lookup( dn, searchAttributes );

        assertEquals( providerEntry, consumerEntry );
    }


    private Entry createEntry() throws Exception
    {
        String user = "user" + entryCount.incrementAndGet();

        String dn = "cn=" + user + ",dc=example,dc=com";

        DefaultEntry entry = new DefaultEntry( schemaManager, dn,
            "objectClass", "person",
            "cn", user,
            "sn", user );

        return entry;
    }


    @CreateDS(
        allowAnonAccess = true,
        name = "ClientServerReplicationIT-provider-replication",
        enableChangeLog = false,
        partitions =
            {
                @CreatePartition(
                    name = "example",
                    suffix = "dc=example,dc=com",
                    indexes =
                        {
                            @CreateIndex(attribute = "objectClass"),
                            @CreateIndex(attribute = "dc"),
                            @CreateIndex(attribute = "ou")
                    },
                    contextEntry = @ContextEntry(entryLdif =
                        "dn: dc=example,dc=com\n" +
                            "objectClass: domain\n" +
                            "dc: example"))
        })
    @CreateLdapServer(transports =
        { @CreateTransport(port = 16000, protocol = "LDAP") })
    public static void startProvider( final CountDownLatch counter ) throws Exception
    {
        DirectoryService provDirService = DSAnnotationProcessor.getDirectoryService();

        // Load the replication controls
        LdapApiService codec = provDirService.getLdapCodecService();
        codec.registerRequestControl( new SyncRequestValueFactory( codec ) );
        codec.registerResponseControl( new SyncDoneValueFactory( codec ) );
        codec.registerResponseControl( new SyncStateValueFactory( codec ) );

        providerServer = ServerAnnotationProcessor.getLdapServer( provDirService );
        providerServer.setReplicationReqHandler( new SyncReplRequestHandler() );
        providerServer.startReplicationProducer();

        Runnable r = new Runnable()
        {

            public void run()
            {
                try
                {
                    schemaManager = providerServer.getDirectoryService().getSchemaManager();
                    providerSession = providerServer.getDirectoryService().getAdminSession();
                    counter.countDown();
                }
                catch ( Exception e )
                {
                    e.printStackTrace();
                }
            }
        };

        Thread t = new Thread( r );
        t.setDaemon( true );
        t.start();
        t.join();
    }


    @CreateDS(
        allowAnonAccess = true,
        enableChangeLog = false,
        name = "ClientServerReplicationIT-consumer-replication",
        partitions =
            {
                @CreatePartition(
                    name = "example",
                    suffix = "dc=example,dc=com",
                    indexes =
                        {
                            @CreateIndex(attribute = "objectClass"),
                            @CreateIndex(attribute = "dc"),
                            @CreateIndex(attribute = "ou")
                    },
                    contextEntry = @ContextEntry(entryLdif =
                        "dn: dc=example,dc=com\n" +
                            "objectClass: domain\n" +
                            "dc: example"))
        })
    @CreateLdapServer(transports =
        { @CreateTransport(port = 17000, protocol = "LDAP") })
    @CreateConsumer
        (
            remotePort = 16000,
            replUserDn = "uid=admin,ou=system",
            replUserPassword = "secret",
            useTls = false,
            baseDn = "dc=example,dc=com",
            refreshInterval = 1000,
            replicaId = 1
        )
        public static void startConsumer( final CountDownLatch counter ) throws Exception
    {
        DirectoryService provDirService = DSAnnotationProcessor.getDirectoryService();
        consumerServer = ServerAnnotationProcessor.getLdapServer( provDirService );

        final ReplicationConsumerImpl consumer = ( ReplicationConsumerImpl ) ServerAnnotationProcessor.createConsumer();

        List<ReplicationConsumer> replConsumers = new ArrayList<ReplicationConsumer>();
        replConsumers.add( consumer );

        consumerServer.setReplConsumers( replConsumers );

        Runnable r = new Runnable()
        {
            public void run()
            {
                try
                {
                    DirectoryService ds = consumerServer.getDirectoryService();

                    Dn configDn = new Dn( ds.getSchemaManager(), "ads-replConsumerId=localhost,ou=system" );
                    consumer.getConfig().setConfigEntryDn( configDn );

                    Entry provConfigEntry = new DefaultEntry( ds.getSchemaManager(), configDn,
                        "objectClass: ads-replConsumer",
                        "ads-replConsumerId: localhost",
                        "ads-searchBaseDN", consumer.getConfig().getBaseDn(),
                        "ads-replProvHostName", consumer.getConfig().getRemoteHost(),
                        "ads-replProvPort", String.valueOf( consumer.getConfig().getRemotePort() ),
                        "ads-replRefreshInterval", String.valueOf( consumer.getConfig().getRefreshInterval() ),
                        "ads-replRefreshNPersist", String.valueOf( consumer.getConfig().isRefreshNPersist() ),
                        "ads-replSearchScope", consumer.getConfig().getSearchScope().getLdapUrlValue(),
                        "ads-replSearchFilter", consumer.getConfig().getFilter(),
                        "ads-replSearchSizeLimit", String.valueOf( consumer.getConfig().getSearchSizeLimit() ),
                        "ads-replSearchTimeOut", String.valueOf( consumer.getConfig().getSearchTimeout() ),
                        "ads-replUserDn", consumer.getConfig().getReplUserDn(),
                        "ads-replUserPassword", consumer.getConfig().getReplUserPassword() );

                    provConfigEntry.put( "ads-replAliasDerefMode", consumer.getConfig().getAliasDerefMode()
                        .getJndiValue() );
                    provConfigEntry.put( "ads-replAttributes", consumer.getConfig().getAttributes() );

                    consumerSession = consumerServer.getDirectoryService().getAdminSession();
                    consumerSession.add( provConfigEntry );
                    counter.countDown();
                }
                catch ( Exception e )
                {
                    e.printStackTrace();
                }
            }
        };

        Thread t = new Thread( r );
        t.setDaemon( true );
        t.start();
        t.join();

        consumerServer.startReplicationConsumers();
    }
}
