/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License, Version 1.0 only
 * (the "License").  You may not use this file except in compliance
 * with the License.
 *
 * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
 * or http://forgerock.org/license/CDDLv1.0.html.
 * See the License for the specific language governing permissions
 * and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at legal-notices/CDDLv1_0.txt.
 * If applicable, add the following below this CDDL HEADER, with the
 * fields enclosed by brackets "[]" replaced with your own identifying
 * information:
 *      Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 *
 *
 *      Copyright 2008-2010 Sun Microsystems, Inc.
 *      Portions Copyright 2011-2015 ForgeRock AS
 */
package org.opends.server.replication.plugin;

import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Callable;

import org.assertj.core.api.Assertions;
import org.forgerock.opendj.ldap.ModificationType;
import org.opends.server.TestCaseUtils;
import org.opends.server.core.DirectoryServer;
import org.opends.server.protocols.internal.InternalClientConnection;
import org.opends.server.replication.ReplicationTestCase;
import org.opends.server.replication.common.CSN;
import org.opends.server.replication.protocol.AddMsg;
import org.opends.server.replication.protocol.LDAPUpdateMsg;
import org.opends.server.replication.protocol.ModifyMsg;
import org.opends.server.replication.service.ReplicationBroker;
import org.opends.server.tools.LDAPModify;
import org.opends.server.types.*;
import org.opends.server.util.TestTimer;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;

import static java.util.concurrent.TimeUnit.*;

import static org.forgerock.opendj.ldap.ResultCode.*;
import static org.forgerock.opendj.ldap.SearchScope.*;
import static org.opends.server.TestCaseUtils.*;
import static org.opends.server.util.CollectionUtils.*;
import static org.testng.Assert.*;

/** Tests the Historical class. */
@SuppressWarnings("javadoc")
public class HistoricalTest extends ReplicationTestCase
{

  private int replServerPort;
  private String testName = "historicalTest";

  /**
   * Set up replication on the test backend.
   */
  @BeforeClass
  @Override
  public void setUp() throws Exception
  {
    super.setUp();

    // Create an internal connection.
    connection = InternalClientConnection.getRootConnection();

    replServerPort = TestCaseUtils.findFreePort();

    // The replication server.
    String replServerStringDN = "cn=Replication Server, " + SYNCHRO_PLUGIN_DN;
    String replServerLdif = "dn: " + replServerStringDN + "\n"
         + "objectClass: top\n"
         + "objectClass: ds-cfg-replication-server\n"
         + "cn: replication Server\n"
         + "ds-cfg-replication-port: " + replServerPort + "\n"
         + "ds-cfg-replication-db-directory: HistoricalTest\n"
         + "ds-cfg-replication-server-id: 102\n";

    // The suffix to be synchronized.
    String synchroServerStringDN = "cn=" + testName + ", cn=domains, " +
      SYNCHRO_PLUGIN_DN;
    String synchroServerLdif = "dn: " + synchroServerStringDN + "\n"
         + "objectClass: top\n"
         + "objectClass: ds-cfg-replication-domain\n"
         + "cn: " + testName + "\n"
         + "ds-cfg-base-dn: " + TEST_ROOT_DN_STRING + "\n"
         + "ds-cfg-replication-server: localhost:" + replServerPort + "\n"
         + "ds-cfg-server-id: 1\n"
         + "ds-cfg-receive-status: true\n";

    configureReplication(replServerLdif, synchroServerLdif);
  }

  /**
   * Tests that the attribute modification history is correctly read from
   * and written to an operational attribute of the entry.
   * Also test that historical is purged according to the purge delay that
   * is provided.
   */
  @Test(enabled=true)
  public void testEncodingAndPurge() throws Exception
  {
    //  Add a test entry.
    TestCaseUtils.addEntry(
        "dn: uid=user.1," + TEST_ROOT_DN_STRING,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: user.1",
        "cn: Aaccf Amar",
        "sn: Amar",
        "givenName: Aaccf",
        "userPassword: password",
        "description: Initial description",
        "displayName: 1"
    );

    // Modify the test entry to give it some history.
    // Test both single and multi-valued attributes.

    String path = TestCaseUtils.createTempFile(
        "dn: uid=user.1," + TEST_ROOT_DN_STRING,
        "changetype: modify",
        "add: cn;lang-en",
        "cn;lang-en: Aaccf Amar",
        "cn;lang-en: Aaccf A Amar",
        "-",
        "replace: description",
        "description: replaced description",
        "-",
        "add: displayName",
        "displayName: 2",
        "-",
        "delete: displayName",
        "displayName: 1",
        "-"
    );

    String[] args =
    {
        "-h", "127.0.0.1",
        "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
        "-D", "cn=Directory Manager",
        "-w", "password",
        "-f", path
    };

    ldapmodify(args);

    args[9] = TestCaseUtils.createTempFile(
        "dn: uid=user.1," + TEST_ROOT_DN_STRING,
        "changetype: modify",
        "replace: displayName",
        "displayName: 2",
        "-"
    );

    ldapmodify(args);

    // Read the entry back to get its history operational attribute.
    DN dn = DN.valueOf("uid=user.1," + TEST_ROOT_DN_STRING);
    Entry entry = DirectoryServer.getEntry(dn);

    List<Attribute> attrs = EntryHistorical.getHistoricalAttr(entry);
    Attribute before = attrs.get(0);

    // Check that encoding and decoding preserves the history information.
    EntryHistorical hist = EntryHistorical.newInstanceFromEntry(entry);
    assertEquals(hist.getLastPurgedValuesCount(),0);
    assertEquals(hist.encodeAndPurge(), before);

    Thread.sleep(1000);

    args[9] = TestCaseUtils.createTempFile(
        "dn: uid=user.1," + TEST_ROOT_DN_STRING,
        "changetype: modify",
        "replace: displayName",
        "displayName: 3",
        "-"
    );
    ldapmodify(args);

    long testPurgeDelayInMillisec = 1000; // 1 sec

    // Read the entry back to get its history operational attribute.
    entry = DirectoryServer.getEntry(dn);
    hist = EntryHistorical.newInstanceFromEntry(entry);
    hist.setPurgeDelay(testPurgeDelayInMillisec);

    // The purge time is not done so the hist attribute should be not empty
    assertFalse(hist.encodeAndPurge().isEmpty());

    // Now wait for the purge time to be done
    Thread.sleep(testPurgeDelayInMillisec + 200);

    // Read the entry back to get its history operational attribute.
    // The hist attribute should now be empty since purged
    entry = DirectoryServer.getEntry(dn);
    hist = EntryHistorical.newInstanceFromEntry(entry);
    hist.setPurgeDelay(testPurgeDelayInMillisec);
    assertTrue(hist.encodeAndPurge().isEmpty());
    assertEquals(hist.getLastPurgedValuesCount(),11);
  }

  /**
   * The scenario for this test case is that two modify operations occur at
   * two different servers at nearly the same time, each operation adding a
   * different value for a single-valued attribute.  Replication then
   * replays the operations and we expect the conflict to be resolved on both
   * servers by keeping whichever value was actually added first.
   * For the unit test, we employ a single directory server.  We use the
   * broker API to simulate the ordering that would happen on the first server
   * on one entry, and the reverse ordering that would happen on the
   * second server on a different entry.  Confused yet?
   */
  @Test(enabled=true, groups="slow")
  public void conflictSingleValue() throws Exception
  {
    final DN dn1 = DN.valueOf("cn=test1," + TEST_ROOT_DN_STRING);
    final DN dn2 = DN.valueOf("cn=test2," + TEST_ROOT_DN_STRING);
    final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING);
    final AttributeType attrType = DirectoryServer.getAttributeTypeOrNull("displayname");
    final AttributeType entryuuidType = DirectoryServer.getAttributeTypeOrNull("entryuuid");

    /*
     * Open a session to the replicationServer using the broker API.
     * This must use a different serverId to that of the directory server.
     */
    ReplicationBroker broker =
      openReplicationSession(baseDN, 2, 100, replServerPort, 1000);


    // Clear the backend and create top entrye
    TestCaseUtils.initializeTestBackend(true);

    // Add the first test entry.
    TestCaseUtils.addEntry(
         "dn: cn=test1," + TEST_ROOT_DN_STRING,
         "objectClass: top",
         "objectClass: person",
         "objectClass: organizationalPerson",
         "objectClass: inetOrgPerson",
         "cn: test1",
         "sn: test"
       );

    // Read the entry back to get its UUID.
    String entryuuid = getEntryValue(dn1, entryuuidType);

    // Add the second test entry.
    TestCaseUtils.addEntry(
         "dn: cn=test2," + TEST_ROOT_DN_STRING,
         "objectClass: top",
         "objectClass: person",
         "objectClass: organizationalPerson",
         "objectClass: inetOrgPerson",
         "cn: test2",
         "sn: test",
         "description: Description"
       );

    // Read the entry back to get its UUID.
    String entryuuid2 = getEntryValue(dn2, entryuuidType);

    long now = System.currentTimeMillis();
    final int serverId1 = 3;
    final int serverId2 = 4;
    CSN t1 = new CSN(now,    0,  serverId1);
    CSN t2 = new CSN(now+1,  0,  serverId2);

    // Simulate the ordering t1:add:A followed by t2:add:B that would
    // happen on one server.

    // Replay an add of a value A at time t1 on a first server.
    publishModify(broker, t1, dn1, entryuuid, attrType, "A");
    waitUntilEntryValueEquals(dn1, attrType, "A");

    // Replay an add of a value B at time t2 on a second server.
    publishModify(broker, t2, dn1, entryuuid, attrType, "B");

    // Simulate the reverse ordering t2:add:B followed by t1:add:A that
    // would happen on the other server.

    t1 = new CSN(now+3,  0,  serverId1);
    t2 = new CSN(now+4,  0,  serverId2);

    publishModify(broker, t2, dn2, entryuuid2, attrType, "B");
    waitUntilEntryValueEquals(dn2, attrType, "B");

    // Replay an add of a value A at time t1 on a first server.
    publishModify(broker, t1, dn2, entryuuid2, attrType, "A");

    // See how the conflicts were resolved.
    // The two values should be the first value added.
    waitUntilEntryValueEquals(dn1, attrType, "A");
    waitUntilEntryValueEquals(dn2, attrType, "A");

    TestCaseUtils.deleteEntry(dn1);
    TestCaseUtils.deleteEntry(dn2);
  }

  private void waitUntilEntryValueEquals(final DN entryDN, final AttributeType attrType, final String expectedValue)
      throws Exception
  {
    final TestTimer timer = new TestTimer.Builder()
      .maxSleep(2, SECONDS)
      .sleepTimes(100, MILLISECONDS)
      .toTimer();
    timer.repeatUntilSuccess(new Callable<Void>()
    {
      @Override
      public Void call() throws Exception
      {
        assertEquals(getEntryValue(entryDN, attrType), expectedValue);
        return null;
      }
    });
  }

  private String getEntryValue(final DN dn, final AttributeType attrType) throws Exception
  {
    Entry entry = DirectoryServer.getEntry(dn);
    Attribute attr = entry.getExactAttribute(attrType, Collections.<String> emptySet());
    Assertions.assertThat(attr).hasSize(1);
    return attr.iterator().next().toString();
  }

  private static void publishModify(ReplicationBroker broker, CSN changeNum,
      DN dn, String entryuuid, AttributeType attrType, String newValue)
  {
    Attribute attr = Attributes.create(attrType.getNameOrOID(), newValue);
    List<Modification> mods = newArrayList(new Modification(ModificationType.ADD, attr));
    broker.publish(new ModifyMsg(changeNum, dn, mods, entryuuid));
  }

  /**
   * Test that historical information is correctly added when performing ADD,
   * MOD and MODDN operations.
   */
  @Test
  public void historicalAdd() throws Exception
  {
    final DN dn1 = DN.valueOf("cn=testHistoricalAdd,o=test");

    // Clear the backend.
    TestCaseUtils.initializeTestBackend(true);

    // Add the first test entry.
    TestCaseUtils.addEntry(
        "dn: " + dn1,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "cn: test1",
        "sn: test"
    );

    // Read the entry that was just added.
    Entry entry = DirectoryServer.getEntry(dn1);

    // Check that we can build an Add operation from this entry.
    // This will ensure both that the Add historical information is
    // correctly added and also that the code that rebuild operation
    // from this historical information is working.
    Iterable<FakeOperation> ops = EntryHistorical.generateFakeOperations(entry);

    // Perform a few check on the Operation to see that it
    // was correctly generated.
    assertFakeOperations(dn1, entry, ops, 1);

    // Now apply a modifications to the entry and check that the
    // ADD historical information has been preserved.
    TestCaseUtils.applyModifications(false,
        "dn: " + dn1,
        "changetype: modify",
        "add: description",
    "description: foo");

    // Read the modified entry.
    entry = DirectoryServer.getEntry(dn1);

    // use historical information to generate new list of operations
    // equivalent to the operations that have been applied to this entry.
    ops = EntryHistorical.generateFakeOperations(entry);

    // Perform a few check on the operation list to see that it
    // was correctly generated.
    assertFakeOperations(dn1, entry, ops, 2);

    // rename the entry.
    TestCaseUtils.applyModifications(false,
        "dn: " + dn1,
        "changetype: moddn",
        "newrdn: cn=test2",
    "deleteoldrdn: 1");

    // Read the modified entry.
    final DN dn2 = DN.valueOf("cn=test2,o=test");
    entry = DirectoryServer.getEntry(dn2);

    // use historical information to generate new list of operations
    // equivalent to the operations that have been applied to this entry.
    ops = EntryHistorical.generateFakeOperations(entry);

    // Perform a few check on the operation list to see that it
    // was correctly generated.
    assertFakeOperations(dn2, entry, ops, 3);

    // Now clear the backend and try to run the generated operations
    // to check that applying them do lead to an equivalent result.
    TestCaseUtils.initializeTestBackend(true);

    for (FakeOperation fake : ops)
    {
      LDAPUpdateMsg msg = (LDAPUpdateMsg) fake.generateMessage();
      Operation op =
        msg.createOperation(InternalClientConnection.getRootConnection());
      op.setInternalOperation(true);
      op.setSynchronizationOperation(true);
      op.run();
    }

    Entry newEntry = DirectoryServer.getEntry(dn2);
    assertEquals(entry.getName(), newEntry.getName());
  }

  /**
   * Performs a few check on the provided ADD operations, particularly
   * that a ADDmsg can be created from it with valid values for fields
   * DN, entryuid, ...)
   */
  private void assertFakeOperations(final DN dn1, Entry entry,
      Iterable<FakeOperation> ops, int assertCount) throws Exception
  {
    int count = 0;
    for (FakeOperation op : ops)
    {
      count++;
      if (op instanceof FakeAddOperation)
      {
        // perform a few check on the Operation to see that it
        // was correctly generated :
        // - the dn should be dn1,
        // - the entry id and the parent id should match the ids from the entry
        FakeAddOperation addOp = (FakeAddOperation) op;
        assertNotNull(addOp.getCSN());
        AddMsg addmsg = addOp.generateMessage();
        assertEquals(dn1, addmsg.getDN());
        assertEquals(addmsg.getEntryUUID(), EntryHistorical.getEntryUUID(entry));
        String parentId = LDAPReplicationDomain.findEntryUUID(dn1.parent());
        assertEquals(addmsg.getParentEntryUUID(), parentId);

        addmsg.createOperation(InternalClientConnection.getRootConnection());
      }
      else
      {
        // The first operation should be an ADD operation.
        assertTrue(count != 1,
            "FakeAddOperation was not correctly generated from historical information");
      }
    }

      assertEquals(count, assertCount);
    }

  /**
   * Test the task that purges the replication historical stored in the user
   * entry.
   * Steps :
   * - creates entry containing historical
   * - wait for the purge delay
   * - launch the purge task
   * - verify that all historical has been purged
   *
   * TODO: another test should be written that configures the task no NOT have
   * the time to purge everything in 1 run .. and thus to relaunch it to finish
   * the purge. And verify that the second run starts on the CSN where
   * the previous task run had stopped.
   */
  @Test(enabled=true)
  public void testRecurringPurgeIn1Run() throws Exception
  {
    int entryCount = 10;
    addEntriesWithHistorical(1, entryCount);

    // set the purge delay to 1 minute
    // FIXME could we change this setting to also accept seconds?
    // This way this test would not take one minute to run
    // (and it could also fail less often in jenkins).
    TestCaseUtils.dsconfig(
        "set-replication-domain-prop",
        "--provider-name","Multimaster Synchronization",
        "--domain-name", testName,
        "--set","conflicts-historical-purge-delay:1m");

    // Let's go past the purge delay
    Thread.sleep(60 * 1000);

    // launch the purge
    final int maxWaitTimeInSeconds = 120;
    Entry purgeConflictsHistoricalTask = TestCaseUtils.makeEntry(
        "dn: ds-task-id=" + UUID.randomUUID() + ",cn=Scheduled Tasks,cn=Tasks",
        "objectclass: top",
        "objectclass: ds-task",
        "objectclass: ds-task-purge-conflicts-historical",
        "ds-task-class-name: org.opends.server.tasks.PurgeConflictsHistoricalTask",
        "ds-task-purge-conflicts-historical-domain-dn: " + TEST_ROOT_DN_STRING,
        "ds-task-purge-conflicts-historical-maximum-duration: " + maxWaitTimeInSeconds);
    executeTask(purgeConflictsHistoricalTask, maxWaitTimeInSeconds * 1000);

    // every entry should be purged from its hist
    int expectedNumberOfEntries = 0;
    waitForSearchResult(TEST_ROOT_DN_STRING, WHOLE_SUBTREE, "(ds-sync-hist=*)", SUCCESS, expectedNumberOfEntries);
  }

  /**
   * Add a provided number of generated entries containing historical.
   * @param dnSuffix A suffix to be added to the dn
   * @param entryCnt The number of entries to create
   */
  private void addEntriesWithHistorical(final int dnSuffix, final int entryCnt) throws Exception
  {
    for (int i=0; i<entryCnt;i++)
    {
      String sdn =  "dn: uid=user"+i+dnSuffix+"," + TEST_ROOT_DN_STRING;

        //  Add a test entry.
        TestCaseUtils.addEntry(
            sdn,
            "objectClass: top",
            "objectClass: person",
            "objectClass: organizationalPerson",
            "objectClass: inetOrgPerson",
            "uid: user"+i,
            "cn: Aaccf Amar",
            "sn: Amar",
            "givenName: Aaccf",
            "userPassword: password",
            "description: Initial description",
            "displayName: 1"
        );

      // Modify the test entry to give it some history.
      // Test both single and multi-valued attributes.

      String path = TestCaseUtils.createTempFile(
          sdn,
          "changetype: modify",
          "add: cn;lang-en",
          "cn;lang-en: Aaccf Amar",
          "cn;lang-en: Aaccf A Amar",
          "-",
          "replace: givenName",
          "givenName: new given",
          "-",
          "replace: userPassword",
          "userPassword: new pass",
          "-",
          "replace: description",
          "description: replaced description",
          "-",
          "replace: sn",
          "sn: replaced sn",
          "-",
          "add: displayName",
          "displayName: 2",
          "-",
          "delete: displayName",
          "displayName: 1",
          "-"
      );

      String[] args =
      {
          "-h", "127.0.0.1",
          "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
          "-D", "cn=Directory Manager",
          "-w", "password",
          "-f", path
      };

      ldapmodify(args);

      args[9] = TestCaseUtils.createTempFile(
          sdn,
          "changetype: modify",
          "replace: displayName",
          "displayName: 2",
          "-"
      );

      ldapmodify(args);
    }

    for (int i = 0; i < entryCnt; i++)
    {
      DN dn = DN.valueOf("uid=user" + i + dnSuffix + "," + TEST_ROOT_DN_STRING);
      getEntry(dn, 1000, true);
    }
  }

  private void ldapmodify(String[] args)
  {
    assertEquals(LDAPModify.mainModify(args, false, null, System.err), 0);
  }
}
