/*
 * 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
 *
 *
 *      Portions Copyright 2012-2015 ForgeRock AS
 */
package org.opends.server.extensions;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;

import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ConditionResult;
import org.forgerock.opendj.ldap.ModificationType;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchScope;
import org.mockito.ArgumentCaptor;
import org.opends.server.TestCaseUtils;
import org.opends.server.admin.server.ConfigurationChangeListener;
import org.opends.server.admin.std.meta.EntityTagVirtualAttributeCfgDefn.ChecksumAlgorithm;
import org.opends.server.admin.std.meta.VirtualAttributeCfgDefn;
import org.opends.server.admin.std.meta.VirtualAttributeCfgDefn.ConflictBehavior;
import org.opends.server.admin.std.meta.VirtualAttributeCfgDefn.Scope;
import org.opends.server.admin.std.server.EntityTagVirtualAttributeCfg;
import org.opends.server.admin.std.server.VirtualAttributeCfg;
import org.opends.server.controls.LDAPAssertionRequestControl;
import org.opends.server.controls.LDAPPostReadRequestControl;
import org.opends.server.controls.LDAPPostReadResponseControl;
import org.opends.server.controls.LDAPPreReadRequestControl;
import org.opends.server.controls.LDAPPreReadResponseControl;
import org.opends.server.core.DirectoryServer;
import org.opends.server.core.ModifyOperation;
import org.opends.server.core.SearchOperation;
import org.opends.server.protocols.internal.InternalClientConnection;
import org.opends.server.protocols.internal.InternalSearchOperation;
import org.opends.server.protocols.internal.Requests;
import org.opends.server.protocols.internal.SearchRequest;
import org.opends.server.protocols.ldap.LDAPFilter;
import org.opends.server.types.Attribute;
import org.opends.server.types.AttributeType;
import org.opends.server.types.Attributes;
import org.opends.server.types.Control;
import org.opends.server.types.DN;
import org.opends.server.types.DirectoryException;
import org.opends.server.types.Entry;
import org.opends.server.types.Modification;
import org.opends.server.types.SearchFilter;
import org.opends.server.types.VirtualAttributeRule;
import org.opends.server.util.StaticUtils;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;

import static java.util.Collections.*;

import static org.mockito.Mockito.*;
import static org.opends.server.protocols.internal.InternalClientConnection.*;
import static org.opends.server.util.CollectionUtils.*;
import static org.testng.Assert.*;

/**
 * A set of test cases for the entity tag virtual attribute provider.
 */
public class EntityTagVirtualAttributeProviderTestCase extends ExtensionsTestCase
{
  private static final String DESCRIPTION = "description";
  private static final String ETAG = "etag";

  private final ByteString dummyValue = ByteString.valueOfUtf8("dummy");
  private final EntityTagVirtualAttributeProvider provider = new EntityTagVirtualAttributeProvider();
  private boolean changeListenerRemoved;
  private boolean changeListenerAdded;

  private final EntityTagVirtualAttributeCfg config = new EntityTagVirtualAttributeCfg()
  {
    private final TreeSet<AttributeType> excludedAttributes = new TreeSet<>();

    @Override
    public void addChangeListener(
        final ConfigurationChangeListener<VirtualAttributeCfg> listener)
    {
      // Should not be called.
      throw new IllegalStateException();
    }

    @Override
    public void addEntityTagChangeListener(
        final ConfigurationChangeListener<EntityTagVirtualAttributeCfg> listener)
    {
      changeListenerAdded = true;
    }

    @Override
    public Class<? extends EntityTagVirtualAttributeCfg> configurationClass()
    {
      // Not needed.
      return null;
    }

    @Override
    public DN dn()
    {
      // Not needed.
      return null;
    }

    @Override
    public AttributeType getAttributeType()
    {
      // Not needed.
      return null;
    }

    @Override
    public SortedSet<DN> getBaseDN()
    {
      // Not needed.
      return null;
    }

    @Override
    public ChecksumAlgorithm getChecksumAlgorithm()
    {
      return ChecksumAlgorithm.ADLER_32;
    }

    @Override
    public ConflictBehavior getConflictBehavior()
    {
      // Not needed.
      return null;
    }

    @Override
    public SortedSet<AttributeType> getExcludedAttribute()
    {
      return excludedAttributes;
    }

    @Override
    public SortedSet<String> getFilter()
    {
      // Not needed.
      return null;
    }

    @Override
    public SortedSet<DN> getGroupDN()
    {
      // Not needed.
      return null;
    }

    @Override
    public String getJavaClass()
    {
      // Not needed.
      return null;
    }

    @Override
    public Scope getScope()
    {
      // Not needed.
      return null;
    }

    @Override
    public boolean isEnabled()
    {
      return true;
    }

    @Override
    public void removeChangeListener(
        final ConfigurationChangeListener<VirtualAttributeCfg> listener)
    {
      // Should not be called.
      throw new IllegalStateException();
    }

    @Override
    public void removeEntityTagChangeListener(
        final ConfigurationChangeListener<EntityTagVirtualAttributeCfg> listener)
    {
      changeListenerRemoved = true;
    }
  };

  /**
   * Ensures that the Directory Server is running.
   *
   * @throws Exception
   *           If an unexpected problem occurs.
   */
  @BeforeClass
  public void startServer() throws Exception
  {
    TestCaseUtils.startServer();

    // Initialize the provider.
    config.getExcludedAttribute().add(DirectoryServer.getAttributeTypeOrNull("modifytimestamp"));
    provider.initializeVirtualAttributeProvider(config);
  }

  /**
   * Tests that approximate matching is not supported.
   */
  @Test
  public void testApproximatelyEqualTo()
  {
    assertEquals(provider.approximatelyEqualTo(null, null, null), ConditionResult.UNDEFINED);
  }

  /**
   * Tests that finalization removes the change listener.
   */
  @Test
  public void testFinalizeVirtualAttributeProvider()
  {
    provider.finalizeVirtualAttributeProvider();
    assertTrue(changeListenerRemoved);
  }

  /**
   * Tests the getValues method returns an ETag whose value represents a 64-bit
   * non-zero long encoded as hex.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testGetValuesBasic() throws Exception
  {
    final Entry e = TestCaseUtils.makeEntry("dn: dc=example,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example");
    getEntityTag(e, getRule());
  }

  /**
   * Tests the getValues method returns a different value for entries which are
   * different.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testGetValuesDifferent() throws Exception
  {
    final Entry e1 = TestCaseUtils.makeEntry("dn: dc=example1,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example1");
    final Entry e2 = TestCaseUtils.makeEntry("dn: dc=example2,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example2");
    VirtualAttributeRule rule = getRule();
    assertFalse(getEntityTag(e1, rule).equals(getEntityTag(e2, rule)));
  }

  /**
   * Tests the getValues method ignores excluded attributes.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testGetValuesIgnoresExcludedAttributes() throws Exception
  {
    final Entry e1 = TestCaseUtils.makeEntry("dn: dc=example,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example");
    final Entry e2 = TestCaseUtils.makeEntry("dn: dc=example,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example",
        "modifyTimestamp: 20120222232918Z");
    VirtualAttributeRule rule = getRule();
    assertEquals(getEntityTag(e1, rule), getEntityTag(e2, rule));
  }

  /**
   * Tests the getValues method returns the same value for entries having the
   * same content but with attributes in a different order.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testGetValuesNormalizedOrder() throws Exception
  {
    final Entry e1 = TestCaseUtils.makeEntry("dn: dc=example,dc=com",
        "objectClass: top", "objectClass: domain", "description: one",
        "description: two", "dc: example");
    final Entry e2 = TestCaseUtils.makeEntry("dn: dc=example,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example",
        "description: two", "description: one");
    VirtualAttributeRule rule = getRule();
    assertEquals(getEntityTag(e1, rule), getEntityTag(e2, rule));
  }

  /**
   * Tests the getValues method returns the same value for different instances
   * of the same entry.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testGetValuesRepeatable() throws Exception
  {
    final Entry e1 = TestCaseUtils.makeEntry("dn: dc=example,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example");
    final Entry e2 = TestCaseUtils.makeEntry("dn: dc=example,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example");
    VirtualAttributeRule rule = getRule();
    assertEquals(getEntityTag(e1, rule), getEntityTag(e2, rule));
  }

  /**
   * Tests that ordering matching is not supported.
   */
  @Test
  public void testGreaterThanOrEqualTo()
  {
    assertEquals(provider.greaterThanOrEqualTo(null, null, null), ConditionResult.UNDEFINED);
  }

  /**
   * Tests hasAllValues() membership.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testHasAllValues() throws Exception
  {
    final Entry e = TestCaseUtils.makeEntry("dn: dc=example,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example");
    VirtualAttributeRule rule = getRule();
    final ByteString value = getEntityTag(e, rule);
    assertTrue(provider.hasAllValues(e, rule, Collections.<ByteString> emptySet()));
    assertTrue(provider.hasAllValues(e, rule, Collections.singleton(value)));
    assertFalse(provider.hasAllValues(e, rule, Collections.singleton(dummyValue)));
    assertFalse(provider.hasAllValues(e, rule, Arrays.asList(value, dummyValue)));
  }

  /**
   * Tests that the etags are always present.
   */
  @Test
  public void testHasValue1()
  {
    assertTrue(provider.hasValue(null, null));
  }

  /**
   * Tests testHasValue membership.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testHasValue2() throws Exception
  {
    final Entry e = TestCaseUtils.makeEntry("dn: dc=example,dc=com",
        "objectClass: top", "objectClass: domain", "dc: example");
    VirtualAttributeRule rule = getRule();
    final ByteString value = getEntityTag(e, rule);
    assertTrue(provider.hasValue(e, rule, value));
    assertFalse(provider.hasValue(e, rule, dummyValue));
  }

  /**
   * Tests that initialization adds the change listener.
   */
  @Test
  public void testInitializeVirtualAttributeProvider()
  {
    // This was actually done during initialization of this test. Check that the
    // listener was registered.
    assertTrue(changeListenerAdded);
  }

  /**
   * Tests that isConfigurationAcceptable always returns true.
   */
  @Test
  public void testIsConfigurationAcceptable()
  {
    assertTrue(provider.isConfigurationAcceptable(config, null));
  }

  /**
   * Tests that the etags are single-valued.
   */
  @Test
  public void testIsMultiValued()
  {
    assertFalse(provider.isMultiValued());
  }

  /**
   * Tests that searching based on etag filters is not supported.
   */
  @Test
  public void testIsSearchable()
  {
    assertFalse(provider.isSearchable(null, null, false));
    assertFalse(provider.isSearchable(null, null, true));
  }

  /**
   * Tests that ordering matching is not supported.
   */
  @Test
  public void testLessThanOrEqualTo()
  {
    assertEquals(provider.lessThanOrEqualTo(null, null, null), ConditionResult.UNDEFINED);
  }

  /**
   * Tests that substring matching is not supported.
   */
  @Test
  public void testMatchesSubstring()
  {
    assertEquals(provider.matchesSubstring(null, null, null, null, null), ConditionResult.UNDEFINED);
  }

  /**
   * Tests that searching based on etag filters is not supported.
   */
  @Test
  public void testProcessSearch()
  {
    final SearchOperation searchOp = mock(SearchOperation.class);

    VirtualAttributeRule rule = new VirtualAttributeRule(
        DirectoryServer.getAttributeTypeOrNull(ETAG), provider,
        Collections.<DN> emptySet(), SearchScope.WHOLE_SUBTREE,
        Collections.<DN> emptySet(), Collections.<SearchFilter> emptySet(),
        VirtualAttributeCfgDefn.ConflictBehavior.REAL_OVERRIDES_VIRTUAL);
    provider.processSearch(rule, searchOp);

    final ArgumentCaptor<ResultCode> resultCode = ArgumentCaptor.forClass(ResultCode.class);
    verify(searchOp).setResultCode(resultCode.capture());
    assertEquals(resultCode.getValue(), ResultCode.UNWILLING_TO_PERFORM);

    final ArgumentCaptor<LocalizableMessage> errorMsg = ArgumentCaptor.forClass(LocalizableMessage.class);
    verify(searchOp).appendErrorMessage(errorMsg.capture());
    assertNotNull(errorMsg.getValue());
  }

  /**
   * Simulates the main use case for entity tag support: optimistic concurrency.
   * <p>
   * This test reads an entry requesting its etag, then performs an update using
   * an assertion control to prevent the change from being applied if the etag
   * has changed since the read was performed.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testOptimisticConcurrency() throws Exception
  {
    // Use an internal connection.
    DN userDN = DN.valueOf("uid=test.user,ou=People,o=test");
    InternalClientConnection conn = getRootConnection();

    // Create a test backend containing the user entry to be modified.
    TestCaseUtils.initializeTestBackend(true);

    // @formatter:off
    TestCaseUtils.addEntries(
      "dn: ou=People,o=test",
      "objectClass: top",
      "objectClass: organizationalUnit",
      "ou: People",
      "",
      "dn: uid=test.user,ou=People,o=test",
      "objectClass: top",
      "objectClass: person",
      "objectClass: organizationalPerson",
      "objectClass: inetOrgPerson",
      "uid: test.user",
      "givenName: Test",
      "sn: User",
      "cn: Test User",
      "userPassword: password");
    // @formatter:on

    // Read the user entry and get the etag.
    Entry e1 = readEntry(userDN);
    String etag1 = e1.parseAttribute(ETAG).asString();
    assertNotNull(etag1);

    // Apply a change using the assertion control for optimistic concurrency.
    Attribute attr = Attributes.create(DESCRIPTION, "first modify");
    List<Modification> mods = newArrayList(new Modification(ModificationType.REPLACE, attr));
    Control c = new LDAPAssertionRequestControl(true, LDAPFilter.createEqualityFilter(ETAG, ByteString.valueOfUtf8(etag1)));
    List<Control> ctrls = Collections.singletonList(c);
    ModifyOperation modifyOperation = conn.processModify(userDN, mods, ctrls);
    assertEquals(modifyOperation.getResultCode(), ResultCode.SUCCESS);

    // Reread the entry and check that the description has been added and that
    // the etag has changed.
    Entry e2 = readEntry(userDN);

    String etag2 = e2.parseAttribute(ETAG).asString();
    assertNotNull(etag2);
    assertFalse(etag1.equals(etag2));

    String description2 = e2.parseAttribute(DESCRIPTION).asString();
    assertNotNull(description2);
    assertEquals(description2, "first modify");

    // Simulate a concurrent update: perform another update using the old etag.
    Attribute attr2 = Attributes.create(DESCRIPTION, "second modify");
    mods = newArrayList(new Modification(ModificationType.REPLACE, attr2));
    modifyOperation = conn.processModify(userDN, mods, ctrls);
    assertEquals(modifyOperation.getResultCode(), ResultCode.ASSERTION_FAILED);

    // Reread the entry and check that the description and etag have not changed
    Entry e3 = readEntry(userDN);

    String etag3 = e3.parseAttribute(ETAG).asString();
    assertNotNull(etag3);
    assertEquals(etag2, etag3);

    String description3 = e3.parseAttribute(DESCRIPTION).asString();
    assertNotNull(description3);
    assertEquals(description3, description2);
  }

  /**
   * Tests that the etag returned with a pre-read control after a modify
   * operation is correct. See OPENDJ-861.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testPreReadControl() throws Exception
  {
    DN userDN = DN.valueOf("uid=test.user,ou=People,o=test");
    InternalClientConnection conn = getRootConnection();

    // Create a test backend containing the user entry to be modified.
    TestCaseUtils.initializeTestBackend(true);

    // @formatter:off
    TestCaseUtils.addEntries(
      "dn: ou=People,o=test",
      "objectClass: top",
      "objectClass: organizationalUnit",
      "ou: People",
      "",
      "dn: uid=test.user,ou=People,o=test",
      "objectClass: top",
      "objectClass: person",
      "objectClass: organizationalPerson",
      "objectClass: inetOrgPerson",
      "uid: test.user",
      "givenName: Test",
      "sn: User",
      "cn: Test User",
      "userPassword: password",
      "description: initial value");
    // @formatter:on

    // Read the user entry and get the etag.
    Entry e1 = readEntry(userDN);
    String etag1 = e1.parseAttribute(ETAG).asString();
    assertNotNull(etag1);

    // Apply a change using the pre and post read controls.
    Attribute attr = Attributes.create(DESCRIPTION, "modified value");
    List<Modification> mods = newArrayList(new Modification(ModificationType.REPLACE, attr));
    List<Control> ctrls = singletonList((Control) new LDAPPreReadRequestControl(true, singleton(ETAG)));
    ModifyOperation modifyOperation = conn.processModify(userDN, mods, ctrls);
    assertEquals(modifyOperation.getResultCode(), ResultCode.SUCCESS);

    // Reread the entry and check that the description has been added and that
    // the etag has changed.
    Entry e2 = readEntry(userDN);

    String etag2 = e2.parseAttribute(ETAG).asString();
    assertNotNull(etag2);
    assertFalse(etag1.equals(etag2));

    String description2 = e2.parseAttribute(DESCRIPTION).asString();
    assertNotNull(description2);
    assertEquals(description2, "modified value");

    // Now check that the pre-read is the same as the initial etag.
    LDAPPreReadResponseControl preReadControl = getLDAPPreReadResponseControl(modifyOperation);
    String etagPreRead = preReadControl.getSearchEntry().parseAttribute(ETAG).asString();
    assertEquals(etagPreRead, etag1);
  }

  /**
   * Tests that the etag returned with a post-read control after a modify
   * operation is correct. See OPENDJ-861.
   *
   * @throws Exception
   *           If an unexpected exception occurred.
   */
  @Test
  public void testPostReadControl() throws Exception
  {
    DN userDN = DN.valueOf("uid=test.user,ou=People,o=test");
    InternalClientConnection conn = getRootConnection();

    // Create a test backend containing the user entry to be modified.
    TestCaseUtils.initializeTestBackend(true);

    // @formatter:off
    TestCaseUtils.addEntries(
      "dn: ou=People,o=test",
      "objectClass: top",
      "objectClass: organizationalUnit",
      "ou: People",
      "",
      "dn: uid=test.user,ou=People,o=test",
      "objectClass: top",
      "objectClass: person",
      "objectClass: organizationalPerson",
      "objectClass: inetOrgPerson",
      "uid: test.user",
      "givenName: Test",
      "sn: User",
      "cn: Test User",
      "userPassword: password",
      "description: initial value");
    // @formatter:on

    // Read the user entry and get the etag.
    Entry e1 = readEntry(userDN);
    String etag1 = e1.parseAttribute(ETAG).asString();
    assertNotNull(etag1);

    // Apply a change using the pre and post read controls.
    Attribute attr = Attributes.create(DESCRIPTION, "modified value");
    List<Modification> mods = newArrayList(new Modification(ModificationType.REPLACE, attr));
    List<Control> ctrls = singletonList((Control) new LDAPPostReadRequestControl(true, singleton(ETAG)));
    ModifyOperation modifyOperation = conn.processModify(userDN, mods, ctrls);
    assertEquals(modifyOperation.getResultCode(), ResultCode.SUCCESS);

    // Reread the entry and check that the description has been added and that
    // the etag has changed.
    Entry e2 = readEntry(userDN);

    String etag2 = e2.parseAttribute(ETAG).asString();
    assertNotNull(etag2);
    assertFalse(etag1.equals(etag2));

    String description2 = e2.parseAttribute(DESCRIPTION).asString();
    assertNotNull(description2);
    assertEquals(description2, "modified value");

    // Now check that the post-read is the same as the initial etag.
    LDAPPostReadResponseControl postReadControl = getLDAPPostReadResponseControl(modifyOperation);
    String etagPostRead = postReadControl.getSearchEntry().parseAttribute(ETAG).asString();
    assertEquals(etagPostRead, etag2);
  }

  private LDAPPostReadResponseControl getLDAPPostReadResponseControl(ModifyOperation modifyOperation)
  {
    for (Control control : modifyOperation.getResponseControls())
    {
      if (control instanceof LDAPPostReadResponseControl)
      {
        return (LDAPPostReadResponseControl) control;
      }
    }
    fail("Expected the ModifyOperation to have a LDAPPostReadResponseControl");
    return null;
  }

  private LDAPPreReadResponseControl getLDAPPreReadResponseControl(ModifyOperation modifyOperation)
  {
    for (Control control : modifyOperation.getResponseControls())
    {
      if (control instanceof LDAPPreReadResponseControl)
      {
        return (LDAPPreReadResponseControl) control;
      }
    }
    fail("Expected the ModifyOperation to have a LDAPPreReadResponseControl");
    return null;
  }

  private Entry readEntry(DN userDN) throws DirectoryException
  {
    SearchRequest request = Requests.newSearchRequest(userDN, SearchScope.BASE_OBJECT).addAttribute("*", ETAG);
    InternalSearchOperation searchOperation = getRootConnection().processSearch(request);
    assertEquals(searchOperation.getResultCode(), ResultCode.SUCCESS);
    assertEquals(searchOperation.getSearchEntries().size(), 1);
    Entry e = searchOperation.getSearchEntries().get(0);
    assertNotNull(e);
    return e;
  }

  private ByteString getEntityTag(final Entry e, VirtualAttributeRule rule)
  {
    final Attribute values = provider.getValues(e, rule);
    assertEquals(values.size(), 1);
    final ByteString value = values.iterator().next();
    assertEquals(value.length(), 16);
    for (int i = 0; i < 16; i++)
    {
      assertTrue(StaticUtils.isHexDigit(value.byteAt(i)));
      if (value.byteAt(i) != 0x30)
      {
        return value;
      }
    }
    fail("Expected to find a non zero byte");
    return null;
  }

  private VirtualAttributeRule getRule()
  {
    AttributeType type = DirectoryServer.getAttributeTypeOrNull("etag");
    return new VirtualAttributeRule(type, provider,
        Collections.<DN>emptySet(), SearchScope.WHOLE_SUBTREE,
        Collections.<DN>emptySet(),
        Collections.<SearchFilter>emptySet(),
        VirtualAttributeCfgDefn.ConflictBehavior.VIRTUAL_OVERRIDES_REAL);
  }

}
