/*
 * 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 2006-2010 Sun Microsystems, Inc.
 *      Portions Copyright 2011-2015 ForgeRock AS
 */
package org.opends.server.core;

import static org.opends.messages.CoreMessages.*;
import static org.opends.server.config.ConfigConstants.*;
import static org.opends.server.protocols.internal.InternalClientConnection.*;
import static org.opends.server.schema.SchemaConstants.*;
import static org.opends.server.util.StaticUtils.*;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;

import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.i18n.LocalizableMessageBuilder;
import org.forgerock.i18n.slf4j.LocalizedLogger;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ConditionResult;
import org.forgerock.opendj.ldap.GeneralizedTime;
import org.forgerock.opendj.ldap.ModificationType;
import org.forgerock.opendj.ldap.ResultCode;
import org.opends.server.admin.std.meta.PasswordPolicyCfgDefn;
import org.opends.server.api.AccountStatusNotificationHandler;
import org.opends.server.api.AuthenticationPolicyState;
import org.opends.server.api.PasswordGenerator;
import org.opends.server.api.PasswordStorageScheme;
import org.opends.server.api.PasswordValidator;
import org.opends.server.protocols.internal.InternalClientConnection;
import org.opends.server.protocols.ldap.LDAPAttribute;
import org.opends.server.schema.AuthPasswordSyntax;
import org.opends.server.schema.GeneralizedTimeSyntax;
import org.opends.server.schema.UserPasswordSyntax;
import org.opends.server.types.AccountStatusNotification;
import org.opends.server.types.AccountStatusNotificationProperty;
import org.opends.server.types.AccountStatusNotificationType;
import org.opends.server.types.Attribute;
import org.opends.server.types.AttributeBuilder;
import org.opends.server.types.AttributeType;
import org.opends.server.types.Attributes;
import org.opends.server.types.DirectoryException;
import org.opends.server.types.Entry;
import org.opends.server.types.Modification;
import org.opends.server.types.Operation;
import org.opends.server.types.RawModification;

/**
 * This class provides a data structure for holding password policy state
 * information for a user account.
 */
public final class PasswordPolicyState extends AuthenticationPolicyState
{
  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();


  /** The string representation of the user's DN. */
  private final String userDNString;

  /** The password policy with which the account is associated. */
  private final PasswordPolicy passwordPolicy;

  /** The current time for use in all password policy calculations. */
  private final long currentTime;

  /** The time that the user's password was last changed. */
  private long passwordChangedTime = Long.MIN_VALUE;

  /** Indicates whether the user's account is expired. */
  private ConditionResult isAccountExpired = ConditionResult.UNDEFINED;
  /** Indicates whether the user's password is expired. */
  private ConditionResult isPasswordExpired = ConditionResult.UNDEFINED;
  /** Indicates whether the warning to send to the client would be the first warning for the user. */
  private ConditionResult isFirstWarning = ConditionResult.UNDEFINED;
  /** Indicates whether the user's account is locked by the idle lockout. */
  private ConditionResult isIdleLocked = ConditionResult.UNDEFINED;
  /**
   * Indicates whether the user may use a grace login if the password is expired and there are one
   * or more grace logins remaining.
   */
  private ConditionResult mayUseGraceLogin = ConditionResult.UNDEFINED;
  /** Indicates whether the user's password must be changed. */
  private ConditionResult mustChangePassword = ConditionResult.UNDEFINED;
  /** Indicates whether the user should be warned of an upcoming expiration. */
  private ConditionResult shouldWarn = ConditionResult.UNDEFINED;

  /** The number of seconds until the user's account is automatically unlocked. */
  private int secondsUntilUnlock = Integer.MIN_VALUE;

  /** The set of authentication failure times for this user. */
  private List<Long> authFailureTimes;
  /** The set of grace login times for this user. */
  private List<Long> graceLoginTimes;

  /** The time that the user's account should expire (or did expire). */
  private long accountExpirationTime = Long.MIN_VALUE;
  /** The time that the user's entry was locked due to too many authentication failures. */
  private long failureLockedTime = Long.MIN_VALUE;
  /** The time that the user last authenticated to the Directory Server. */
  private long lastLoginTime = Long.MIN_VALUE;
  /** The time that the user's password should expire (or did expire). */
  private long passwordExpirationTime = Long.MIN_VALUE;
  /** The last required change time with which the user complied. */
  private long requiredChangeTime = Long.MIN_VALUE;
  /** The time that the user was first warned about an upcoming expiration. */
  private long warnedTime = Long.MIN_VALUE;

  /** The set of modifications that should be applied to the user's entry. */
  private LinkedList<Modification> modifications = new LinkedList<>();



  /**
   * Creates a new password policy state object with the provided information.
   * <p>
   * Note that this version of the constructor should only be used for testing purposes when the tests should be
   * evaluated with a fixed time rather than the actual current time. For all other purposes, the other constructor
   * should be used.
   * </p>
   *
   * @param policy      The password policy associated with the state.
   * @param userEntry   The entry with the user account.
   * @param currentTime The time to use as the current time for all time-related determinations.
   */
  PasswordPolicyState(PasswordPolicy policy, Entry userEntry, long currentTime)
  {
    super(userEntry);
    this.currentTime = currentTime;
    this.userDNString = userEntry.getName().toString();
    this.passwordPolicy = policy;
  }



   /**
    * Retrieves the value of the specified attribute as a string.
    *
    * @param  attributeType  The attribute type whose value should be retrieved.
    *
    * @return  The value of the specified attribute as a string, or <CODE>null</CODE> if there is no such value.
    */
  private String getValue(AttributeType attributeType)
  {
    Attribute attr = getFirstAttributeNotEmpty(attributeType);
    String stringValue = attr != null ? attr.iterator().next().toString() : null;
    if (stringValue == null)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning null because attribute %s does not exist in user entry %s",
            attributeType.getNameOrOID(), userDNString);
      }
    }
    else
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning value %s for user %s", stringValue, userDNString);
      }
    }

    return stringValue;
  }

  private Attribute getFirstAttributeNotEmpty(AttributeType attributeType)
  {
    List<Attribute> attrList = userEntry.getAttribute(attributeType);
    if (attrList != null)
    {
      for (Attribute a : attrList)
      {
        if (!a.isEmpty())
        {
          return a;
        }
      }
    }
    return null;
  }

  /**
   * Retrieves the set of values of the specified attribute from the user's entry in generalized time format.
   *
   * @param  attributeType  The attribute type whose values should be parsed as generalized time values.
   *
   * @return  The set of generalized time values, or an empty list if there are none.
   *
   * @throws  DirectoryException  If a problem occurs while attempting to decode a value as a generalized time.
   */
  private List<Long> getGeneralizedTimes(AttributeType attributeType)
          throws DirectoryException
  {
    ArrayList<Long> timeValues = new ArrayList<>();

    List<Attribute> attrList = userEntry.getAttribute(attributeType);
    if (attrList != null)
    {
      for (Attribute a : attrList)
      {
        for (ByteString v : a)
        {
          try
          {
            timeValues.add(GeneralizedTime.valueOf(v.toString()).getTimeInMillis());
          }
          catch (Exception e)
          {
            logger.traceException(e, "Unable to decode value %s for attribute %s in user entry %s",
                v, attributeType.getNameOrOID(), userDNString);

            throw new DirectoryException(ResultCode.INVALID_ATTRIBUTE_SYNTAX,
                ERR_PWPSTATE_CANNOT_DECODE_GENERALIZED_TIME.get(v, attributeType.getNameOrOID(), userDNString, e),
                e);
          }
        }
      }
    }

    if (timeValues.isEmpty())
    {
      logger.trace("Returning an empty list because attribute %s does not exist in user entry %s",
          attributeType.getNameOrOID(), userDNString);
    }
    return timeValues;
  }


  /**
   * Get the password storage scheme used by a given password value.
   *
   * @param  v  The encoded password value to check.
   *
   * @return  The scheme used by the password.
   *
   * @throws  DirectoryException  If the password could not be decoded.
   */
  private PasswordStorageScheme<?> getPasswordStorageScheme(ByteString v) throws DirectoryException
  {
    if (passwordPolicy.isAuthPasswordSyntax())
    {
      String[] pwComps = AuthPasswordSyntax.decodeAuthPassword(v.toString());
      return DirectoryServer.getAuthPasswordStorageScheme(pwComps[0]);
    }
    else
    {
      String[] pwComps = UserPasswordSyntax.decodeUserPassword(v.toString());
      return DirectoryServer.getPasswordStorageScheme(pwComps[0]);
    }
  }

  @Override
  public PasswordPolicy getAuthenticationPolicy()
  {
    return passwordPolicy;
  }



  /**
   * Retrieves the time that the password was last changed.
   *
   * @return  The time that the password was last changed.
   */
  public long getPasswordChangedTime()
  {
    if (passwordChangedTime < 0)
    {
      // Get the password changed time for the user.
      try
      {
        passwordChangedTime = getGeneralizedTime0(userEntry, OP_ATTR_PWPOLICY_CHANGED_TIME_LC);
      }
      catch (DirectoryException e)
      {
        /*
         * The password change time could not be parsed (but has been logged in the debug log).
         * The best effort we can do from here is to a) use the current time, b) use the start
         * of the epoch (1/1/1970), or c) use the create time stamp. Lets treat this problem as if the change time
         * attribute did not exist and resort to the create time stamp.
         */
      }

      if (passwordChangedTime < 0)
      {
        // Get the time that the user's account was created.
        try
        {
          passwordChangedTime = getGeneralizedTime0(userEntry, OP_ATTR_CREATE_TIMESTAMP_LC);
        }
        catch (DirectoryException e)
        {
          /*
           * The create time stamp could not be parsed (but has been logged in the debug log).
           * The best effort we can do from here is to a) use the current time, or b) use the start of
            * the epoch (1/1/1970). Lets treat this problem as if the change time attribute did not exist
           * and use the start of the epoch. Doing so stands a greater chance of forcing a password change.
           */
        }

        if (passwordChangedTime < 0)
        {
          passwordChangedTime = 0;

          if (logger.isTraceEnabled())
          {
            logger.trace(
                "Could not determine password changed time for " + "user %s.", userDNString);
          }
        }
      }
    }

    return passwordChangedTime;
  }


  private long getGeneralizedTime0(Entry userEntry, String attrName) throws DirectoryException
  {
    return getGeneralizedTime(userEntry, DirectoryServer.getAttributeTypeOrDefault(attrName));
  }

  /**
   * Retrieves the time that this password policy state object was created.
   *
   * @return  The time that this password policy state object was created.
   */
  public long getCurrentTime()
  {
    return currentTime;
  }



  /**
   * Retrieves the unmodifiable set of values for the password attribute from the user entry.
   *
   * @return The unmodifiable set of values for the password attribute from the user entry.
   */
  public Set<ByteString> getPasswordValues()
  {
    final Attribute attr = getFirstAttributeNotEmpty(passwordPolicy.getPasswordAttribute());
    if (attr != null)
    {
      Set<ByteString> values = new LinkedHashSet<>(attr.size());
      for (ByteString value : attr)
      {
        values.add(value);
      }
      return Collections.unmodifiableSet(values);
    }
    return Collections.emptySet();
  }



  /**
   * Sets a new value for the password changed time equal to the current time.
   */
  public void setPasswordChangedTime()
  {
    setPasswordChangedTime(currentTime);
  }


  /**
   * Sets a new value for the password changed time equal to the specified time.
   * This method should generally only be used for testing purposes, since the variant that uses
   * the current time is preferred almost everywhere else.
   *
   * @param  passwordChangedTime  The time to use
   */
  public void setPasswordChangedTime(long passwordChangedTime)
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Setting password changed time for user %s to current time of %d", userDNString, currentTime);
    }

    // passwordChangedTime is computed in the constructor from values in the entry.
    if (getPasswordChangedTime() != passwordChangedTime)
    {
      this.passwordChangedTime = passwordChangedTime;

      String timeValue = GeneralizedTimeSyntax.format(passwordChangedTime);
      Attribute a = Attributes.create(OP_ATTR_PWPOLICY_CHANGED_TIME, timeValue);

      modifications.add(new Modification(ModificationType.REPLACE, a, true));
    }
  }



  /**
   * Removes the password changed time value from the user's entry.  This should only be used for testing
   * purposes, as it can really mess things up if you don't know what you're doing.
   */
  public void clearPasswordChangedTime()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Clearing password changed time for user %s", userDNString);
    }

    Attribute a = Attributes.empty(OP_ATTR_PWPOLICY_CHANGED_TIME_LC);
    modifications.add(new Modification(ModificationType.REPLACE, a, true));


    // Fall back to using the entry creation time as the password changed time, if it's defined.
    // Otherwise, use a value of zero.
    try
    {
      passwordChangedTime = getGeneralizedTime0(userEntry, OP_ATTR_CREATE_TIMESTAMP_LC);
      if (passwordChangedTime < 0)
      {
        passwordChangedTime = 0;
      }
    }
    catch (Exception e)
    {
      passwordChangedTime = 0;
    }
  }



  /**
   * Updates the user entry to indicate whether user account has been administratively disabled.
   *
   * @param isDisabled
   *          Indicates whether the user account has been administratively disabled.
   */
  public void setDisabled(boolean isDisabled)
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Updating user %s to set the disabled flag to %b", userDNString, isDisabled);
    }


    if (isDisabled == isDisabled())
    {
      return; // requested state matches current state
    }

    this.isDisabled = ConditionResult.not(this.isDisabled);

    if (isDisabled)
    {
      Attribute a = Attributes.create(OP_ATTR_ACCOUNT_DISABLED, String.valueOf(true));
      modifications.add(new Modification(ModificationType.REPLACE, a, true));
    }
    else
    {
      // erase
      modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(OP_ATTR_ACCOUNT_DISABLED), true));
    }
  }


  /**
   * Indicates whether the user's account is currently expired.
   *
   * @return  <CODE>true</CODE> if the user's account is expired, or <CODE>false</CODE> if not.
   */
  public boolean isAccountExpired()
  {
    if (isAccountExpired != ConditionResult.UNDEFINED)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning stored result of %b for user %s",
            isAccountExpired == ConditionResult.TRUE, userDNString);
      }

      return isAccountExpired == ConditionResult.TRUE;
    }

    try {
      accountExpirationTime = getGeneralizedTime0(userEntry, OP_ATTR_ACCOUNT_EXPIRATION_TIME);
    }
    catch (Exception e)
    {
      logger.traceException(e, "User %s is considered to have an expired account because an error occurred " +
          "while attempting to make the determination.", userDNString);

      isAccountExpired = ConditionResult.TRUE;
      return true;
    }

    if (accountExpirationTime > currentTime)
    {
      // The user does have an expiration time, but it hasn't arrived yet.
      isAccountExpired = ConditionResult.FALSE;
      logger.trace("The account for user %s is not expired because the expiration time has not yet arrived.",
          userDNString);
    }
    else if (accountExpirationTime >= 0)
    {
      // The user does have an expiration time, and it is in the past.
      isAccountExpired = ConditionResult.TRUE;
      logger.trace("The account for user %s is expired because the expiration time in that account has passed.",
          userDNString);
    }
    else
    {
      // The user doesn't have an expiration time in their entry, so it can't be expired.
      isAccountExpired = ConditionResult.FALSE;
      logger.trace("The account for user %s is not expired because there is no expiration time in the user's entry.",
          userDNString);
    }

    return isAccountExpired == ConditionResult.TRUE;
  }



  /**
   * Retrieves the time at which the user's account will expire.
   *
   * @return  The time at which the user's account will expire, or -1 if it is not configured with an expiration time.
   */
  public long getAccountExpirationTime()
  {
    if (accountExpirationTime == Long.MIN_VALUE)
    {
      isAccountExpired();
    }

    return accountExpirationTime;
  }



  /**
   * Sets the user's account expiration time to the specified value.
   *
   * @param  accountExpirationTime  The time that the user's account should expire.
   */
  public void setAccountExpirationTime(long accountExpirationTime)
  {
    if (accountExpirationTime < 0)
    {
      clearAccountExpirationTime();
    }
    else
    {
      String timeStr = GeneralizedTimeSyntax.format(accountExpirationTime);

      if (logger.isTraceEnabled())
      {
        logger.trace("Setting account expiration time for user %s to %s", userDNString, timeStr);
      }

      this.accountExpirationTime = accountExpirationTime;

      Attribute a = Attributes.create(OP_ATTR_ACCOUNT_EXPIRATION_TIME, timeStr);
      modifications.add(new Modification(ModificationType.REPLACE, a, true));
    }
  }



  /**
   * Clears the user's account expiration time.
   */
  public void clearAccountExpirationTime()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Clearing account expiration time for user %s", userDNString);
    }

    accountExpirationTime = -1;

    String attrName = OP_ATTR_ACCOUNT_EXPIRATION_TIME;
    modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(attrName), true));
  }



  /**
   * Retrieves the set of times of failed authentication attempts for the user. If authentication failure
   * time expiration is enabled, and there are expired times in the entry, these times are removed
   * from the instance field and an update is provided to delete those values from the entry.
   *
   * @return The set of times of failed authentication attempts for the user, which will be an empty list
   *         in the case of no valid (unexpired) times in the entry.
   */
  public List<Long> getAuthFailureTimes()
  {
    if (authFailureTimes != null)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning stored auth failure time list of %d elements for user %s",
            authFailureTimes.size(), userDNString);
      }

      return authFailureTimes;
    }

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_FAILURE_TIME_LC, OP_ATTR_PWPOLICY_FAILURE_TIME);
    try
    {
      authFailureTimes = getGeneralizedTimes(type);
    }
    catch (Exception e)
    {
      logger.traceException(e, "Error while processing auth failure times for user %s", userDNString);

      authFailureTimes = new ArrayList<>();
      modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(type), true));
      return authFailureTimes;
    }

    if (authFailureTimes.isEmpty())
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning an empty auth failure time list for user %s because the attribute" +
                " is absent from the entry.", userDNString);
      }

      return authFailureTimes;
    }

    // Remove any expired failures from the list.
    if (passwordPolicy.getLockoutFailureExpirationInterval() > 0)
    {
      LinkedHashSet<ByteString> valuesToRemove = null;

      long expirationTime = currentTime - passwordPolicy.getLockoutFailureExpirationInterval() * 1000L;
      Iterator<Long> iterator = authFailureTimes.iterator();
      while (iterator.hasNext())
      {
        long l = iterator.next();
        if (l < expirationTime)
        {
          if (logger.isTraceEnabled())
          {
            logger.trace("Removing expired auth failure time %d for user %s", l, userDNString);
          }

          iterator.remove();

          if (valuesToRemove == null)
          {
            valuesToRemove = new LinkedHashSet<>();
          }

          valuesToRemove.add(ByteString.valueOfUtf8(GeneralizedTimeSyntax.format(l)));
        }
      }

      if (valuesToRemove != null)
      {
        Attribute a = newAttribute(type, valuesToRemove);
        modifications.add(new Modification(ModificationType.DELETE, a, true));
      }
    }

    if (logger.isTraceEnabled())
    {
      logger.trace("Returning auth failure time list of %d elements for user %s",
          authFailureTimes.size(), userDNString);
    }

    return authFailureTimes;
  }


  /**
   * Updates the set of authentication failure times to include the current time.
   * If the number of failures reaches the policy configuration limit, lock the account.
   */
  public void updateAuthFailureTimes()
  {
    if (passwordPolicy.getLockoutFailureCount() <= 0)
    {
      return;
    }

    if (logger.isTraceEnabled())
    {
      logger.trace("Updating authentication failure times for user %s", userDNString);
    }


    List<Long> failureTimes = getAuthFailureTimes();
    long highestFailureTime = computeHighestTime(failureTimes);
    // Update the current policy state
    failureTimes.add(highestFailureTime);

    // And the attribute in the user entry
    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_FAILURE_TIME_LC, OP_ATTR_PWPOLICY_FAILURE_TIME);
    Attribute addAttr = Attributes.create(type, GeneralizedTimeSyntax.format(highestFailureTime));
    modifications.add(new Modification(ModificationType.ADD, addAttr, true));

    // Now check to see if there have been sufficient failures to lock the account.
    int lockoutCount = passwordPolicy.getLockoutFailureCount();
    if (lockoutCount > 0 && lockoutCount <= authFailureTimes.size())
    {
      setFailureLockedTime(highestFailureTime);
      if (logger.isTraceEnabled())
      {
        logger.trace("Locking user account %s due to too many failures.", userDNString);
      }
    }
  }



  /**
   * Explicitly specifies the auth failure times for the associated user.  This should generally only be used
   * for testing purposes.  Note that it will also set or clear the locked time as appropriate.
   *
   * @param  authFailureTimes  The set of auth failure times to use for the account.  An empty list or
   *                           {@code null} will clear the account of any existing failures.
   */
  public void setAuthFailureTimes(List<Long> authFailureTimes)
  {
    if (authFailureTimes == null || authFailureTimes.isEmpty())
    {
      clearAuthFailureTimes();
      clearFailureLockedTime();
      return;
    }

    this.authFailureTimes = authFailureTimes;

    AttributeBuilder builder = new AttributeBuilder(OP_ATTR_PWPOLICY_FAILURE_TIME_LC);
    long highestFailureTime = -1;

    for (long l : authFailureTimes)
    {
      highestFailureTime = Math.max(l, highestFailureTime);
      builder.add(GeneralizedTimeSyntax.format(l));
    }
    Attribute a = builder.toAttribute();

    modifications.add(new Modification(ModificationType.REPLACE, a, true));

    // Now check to see if there have been sufficient failures to lock the account.
    int lockoutCount = passwordPolicy.getLockoutFailureCount();
    if (lockoutCount > 0 && lockoutCount <= authFailureTimes.size())
    {
      setFailureLockedTime(highestFailureTime);
      if (logger.isTraceEnabled())
      {
        logger.trace("Locking user account %s due to too many failures.", userDNString);
      }
    }
  }



  /**
   * Updates the user entry to remove any record of previous authentication failure times.
   */
  private void clearAuthFailureTimes()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Clearing authentication failure times for user %s", userDNString);
    }

    List<Long> failureTimes = getAuthFailureTimes();
    if (failureTimes.isEmpty())
    {
      return;
    }

    failureTimes.clear(); // Note: failureTimes != this.authFailureTimes

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_FAILURE_TIME_LC, OP_ATTR_PWPOLICY_FAILURE_TIME);
    modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(type), true));
  }


  /**
   * Retrieves the time of an authentication failure lockout for the user.
   *
   * @return  The time of an authentication failure lockout for the user, or -1 if no such time is present in the entry.
   */
  private long getFailureLockedTime()
  {
    if (failureLockedTime != Long.MIN_VALUE)
    {
      return failureLockedTime;
    }

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_LOCKED_TIME_LC, OP_ATTR_PWPOLICY_LOCKED_TIME);
    try
    {
      failureLockedTime = getGeneralizedTime(userEntry, type);
    }
    catch (Exception e)
    {
      logger.traceException(e, "Returning current time for user %s because an error occurred", userDNString);

      failureLockedTime = currentTime;
      return failureLockedTime;
    }

    // An expired locked time is handled in lockedDueToFailures.
    return failureLockedTime;
  }



  /**
    Sets the failure lockout attribute in the entry to the requested time.

    @param time  The time to which to set the entry's failure lockout attribute.
   */
  private void setFailureLockedTime(final long time)
  {
    if (time == getFailureLockedTime())
    {
      return;
    }

    failureLockedTime = time;

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_LOCKED_TIME_LC, OP_ATTR_PWPOLICY_LOCKED_TIME);
    Attribute a = Attributes.create(type, GeneralizedTimeSyntax.format(failureLockedTime));
    modifications.add(new Modification(ModificationType.REPLACE, a, true));
  }



  /**
   * Updates the user entry to remove any record of previous authentication failure lockout.
   */
  private void clearFailureLockedTime()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Clearing failure lockout time for user %s.", userDNString);
    }

    if (-1L == getFailureLockedTime())
    {
      return;
    }

    failureLockedTime = -1L;

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_LOCKED_TIME_LC, OP_ATTR_PWPOLICY_LOCKED_TIME);
    modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(type), true));
  }


  /**
   * Indicates whether the associated user should be considered locked out as a result of too many
   * authentication failures. In the case of an expired lock-out, this routine produces the update
   * to clear the lock-out attribute and the authentication failure timestamps.
   * In case the failure lockout time is absent from the entry, but sufficient authentication failure
   * timestamps are present in the entry, this routine produces the update to set the lock-out attribute.
   *
   * @return  <CODE>true</CODE> if the user is currently locked out due to too many authentication failures,
   *          or <CODE>false</CODE> if not.
   */
  public boolean lockedDueToFailures()
  {
    // FIXME: Introduce a state field to cache the computed value of this method.
    // Note that only a cached "locked" status can be returned due to the possibility of intervening updates to
    // this.failureLockedTime by updateAuthFailureTimes.

    // Check if the feature is enabled in the policy.
    final int maxFailures = passwordPolicy.getLockoutFailureCount();
    if (maxFailures <= 0)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false for user %s because lockout due to failures is not enabled.", userDNString);
      }

      return false;
    }

    // Get the locked time from the user's entry. If it is present and not expired, the account is locked.
    // If it is absent, the failure timestamps must be checked, since failure timestamps sufficient to lock the
    // account could be produced across the synchronization topology within the synchronization latency.
    // Also, note that IETF draft-behera-ldap-password-policy-09 specifies "19700101000000Z" as the value to be set
    // under a "locked until reset" regime; however, this implementation accepts the value as a locked entry,
    // but observes the lockout expiration policy for all values including this one.
    // FIXME: This "getter" is unusual in that it might produce an update to the entry in two cases.
    // Does it make sense to factor the methods so that, e.g., an expired lockout is reported, and clearing
    // the lockout is left to the caller?
    if (getFailureLockedTime() < 0L)
    {
      // There was no locked time present in the entry; however, sufficient failure times might have accumulated
      // to trigger a lockout.
      if (getAuthFailureTimes().size() < maxFailures)
      {
        if (logger.isTraceEnabled())
        {
          logger.trace("Returning false for user %s because there is no locked time.", userDNString);
        }

        return false;
      }

      // The account isn't locked but should be, so do so now.
      setFailureLockedTime(currentTime);// FIXME: set to max(failureTimes)?

      if (logger.isTraceEnabled())
      {
        logger.trace("Locking user %s because there were enough existing failures even though there was" +
                " no account locked time.", userDNString);
      }
      // Fall through...
    }

    // There is a failure locked time, but it may be expired.
    if (passwordPolicy.getLockoutDuration() > 0)
    {
      final long unlockTime = getFailureLockedTime() + 1000L * passwordPolicy.getLockoutDuration();
      if (unlockTime > currentTime)
      {
        secondsUntilUnlock = (int) ((unlockTime - currentTime) / 1000);

        if (logger.isTraceEnabled())
        {
          logger.trace("Returning true for user %s because there is a locked time and the lockout duration has" +
                  " not been reached.", userDNString);
        }

        return true;
      }

      // The lockout in the entry has expired...
      clearFailureLockout();

      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false for user %s because the existing lockout has expired.", userDNString);
      }

      assert -1L == getFailureLockedTime();
      return false;
    }

    if (logger.isTraceEnabled())
    {
      logger.trace("Returning true for user %s because there is a locked time and no lockout duration.", userDNString);
    }

    assert -1L <= getFailureLockedTime();
    return true;
  }



  /**
   * Retrieves the length of time in seconds until the user's account is automatically unlocked.
   * This should only be called after calling <CODE>lockedDueToFailures</CODE>.
   *
   * @return  The length of time in seconds until the user's account is automatically unlocked, or -1 if the account
   * is not locked or the lockout requires administrative action to clear.
   */
  public int getSecondsUntilUnlock()
  {
    // secondsUntilUnlock is only set when failureLockedTime is present and PasswordPolicy.getLockoutDuration
    // is enabled; hence it is not unreasonable to find secondsUntilUnlock uninitialized.
    assert failureLockedTime != Long.MIN_VALUE;

    return secondsUntilUnlock < 0 ? -1 : secondsUntilUnlock;
  }



  /**
   * Updates the user account to remove any record of a previous lockout due to failed authentications.
   */
  public void clearFailureLockout()
  {
    clearAuthFailureTimes();
    clearFailureLockedTime();
  }



  /**
   * Retrieves the time that the user last authenticated to the Directory Server.
   *
   * @return  The time that the user last authenticated to the Directory Server, or -1 if it cannot be determined.
   */
  public long getLastLoginTime()
  {
    if (lastLoginTime != Long.MIN_VALUE)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning stored last login time of %d for user %s.", lastLoginTime, userDNString);
      }

      return lastLoginTime;
    }

    // The policy configuration must be checked since the entry cannot be evaluated without both an attribute
    // name and timestamp format.
    AttributeType type   = passwordPolicy.getLastLoginTimeAttribute();
    String        format = passwordPolicy.getLastLoginTimeFormat();

    if (type == null || format == null)
    {
      lastLoginTime = -1;
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning -1 for user %s because no last login time will be maintained.", userDNString);
      }

      return lastLoginTime;
    }

    boolean isGeneralizedTime = SYNTAX_GENERALIZED_TIME_NAME.equals(type.getSyntax().getName());
    lastLoginTime = -1;
    List<Attribute> attrList = userEntry.getAttribute(type);

    if (attrList != null)
    {
      for (Attribute a : attrList)
      {
        if (a.isEmpty())
        {
          continue;
        }

        String valueString = a.iterator().next().toString();
        try
        {
          lastLoginTime = parseTime(format, valueString, isGeneralizedTime);

          if (logger.isTraceEnabled())
          {
            logger.trace("Returning last login time of %d for user %s, decoded using current last login time format.",
                lastLoginTime, userDNString);
          }

          return lastLoginTime;
        }
        catch (Exception e)
        {
          logger.traceException(e);

          // This could mean that the last login time was encoded using a previous format.
          for (String f : passwordPolicy.getPreviousLastLoginTimeFormats())
          {
            try
            {
              lastLoginTime = parseTime(f, valueString, isGeneralizedTime);

              if (logger.isTraceEnabled())
              {
                logger.trace("Returning last login time of %d for user %s decoded using previous last login time " +
                    "format of %s.", lastLoginTime, userDNString, f);
              }

              return lastLoginTime;
            }
            catch (Exception e2)
            {
              logger.traceException(e);
            }
          }

          assert lastLoginTime == -1;
          if (logger.isTraceEnabled())
          {
              logger.trace("Returning -1 for user %s because the last login time value %s could not be parsed " +
                  "using any known format.", userDNString, valueString);
          }

          return lastLoginTime;
        }
      }
    }

    assert lastLoginTime == -1;
    if (logger.isTraceEnabled())
    {
      logger.trace("Returning %d for user %s because no last login time value exists.", lastLoginTime, userDNString);
    }

    return lastLoginTime;
  }

  private long parseTime(String format, String time, boolean isGeneralizedTime) throws ParseException
  {
    SimpleDateFormat dateFormat = new SimpleDateFormat(format);
    if (isGeneralizedTime)
    {
      dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
    }
    return dateFormat.parse(time).getTime();
  }

  /**
   * Updates the user entry to set the current time as the last login time.
   */
  public void setLastLoginTime()
  {
    setLastLoginTime(currentTime);
  }



  /**
   * Updates the user entry to use the specified last login time.  This should be used primarily for testing purposes,
   * as the variant that uses the current time should be used most of the time.
   *
   * @param  lastLoginTime  The last login time to set in the user entry.
   */
  public void setLastLoginTime(long lastLoginTime)
  {
    AttributeType type = passwordPolicy.getLastLoginTimeAttribute();
    String format = passwordPolicy.getLastLoginTimeFormat();

    if (type == null || format == null)
    {
      return;
    }

    String timestamp;
    try
    {
      SimpleDateFormat dateFormat = new SimpleDateFormat(format);
      // If the attribute has a Generalized Time syntax, make it UTC time.
      if (SYNTAX_GENERALIZED_TIME_NAME.equals(type.getSyntax().getName()))
      {
        dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
      }
      timestamp = dateFormat.format(new Date(lastLoginTime));
      this.lastLoginTime = dateFormat.parse(timestamp).getTime();
    }
    catch (Exception e)
    {
      logger.traceException(e, "Unable to set last login time for user %s because an error occurred", userDNString);
      return;
    }


    String existingTimestamp = getValue(type);
    if (existingTimestamp != null && timestamp.equals(existingTimestamp))
    {
      logger.trace("Not updating last login time for user %s because the new value matches the existing value.",
          userDNString);
      return;
    }


    Attribute a = Attributes.create(type, timestamp);
    modifications.add(new Modification(ModificationType.REPLACE, a, true));

    logger.trace("Updated the last login time for user %s to %s", userDNString, timestamp);
  }



  /**
   * Clears the last login time from the user's entry.  This should generally be used only for testing purposes.
   */
  public void clearLastLoginTime()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Clearing last login time for user %s", userDNString);
    }

    lastLoginTime = -1;

    modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(OP_ATTR_LAST_LOGIN_TIME), true));
  }


  /**
   * Indicates whether the user's account is currently locked because it has been idle for too long.
   *
   * @return  <CODE>true</CODE> if the user's account is locked because it has been idle for too long,
   *          or <CODE>false</CODE> if not.
   */
  public boolean lockedDueToIdleInterval()
  {
    if (isIdleLocked != ConditionResult.UNDEFINED)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning stored result of %b for user %s", isIdleLocked == ConditionResult.TRUE, userDNString);
      }

      return isIdleLocked == ConditionResult.TRUE;
    }

    // Return immediately if this feature is disabled, since the feature is not responsible for any state attribute
    // in the entry.
    if (passwordPolicy.getIdleLockoutInterval() <= 0)
    {
      isIdleLocked = ConditionResult.FALSE;

      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false for user %s because no idle lockout interval is defined.", userDNString);
      }
      return false;
    }

    long lockTime = currentTime - 1000L * passwordPolicy.getIdleLockoutInterval();
    if (lockTime < 0)
    {
      lockTime = 0;
    }

    long theLastLoginTime = getLastLoginTime();
    if (theLastLoginTime > lockTime || getPasswordChangedTime() > lockTime)
    {
      isIdleLocked = ConditionResult.FALSE;
      if (logger.isTraceEnabled())
      {
        StringBuilder reason = new StringBuilder();
        if(theLastLoginTime > lockTime)
        {
          reason.append("the last login time is in an acceptable window");
        }
        else
        {
          if(theLastLoginTime < 0)
          {
            reason.append("there is no last login time, but ");
          }
          reason.append("the password changed time is in an acceptable window");
        }
        logger.trace("Returning false for user %s because %s.", userDNString, reason);
      }
    }
    else
    {
      isIdleLocked = ConditionResult.TRUE;
      if (logger.isTraceEnabled())
      {
        String reason = theLastLoginTime < 0
            ? "there is no last login time and the password changed time is not in an acceptable window"
            : "neither last login time nor password changed time are in an acceptable window";
        logger.trace("Returning true for user %s because %s.", userDNString, reason);
      }
    }

    return isIdleLocked == ConditionResult.TRUE;
  }



/**
* Indicates whether the user's password must be changed before any other operation can be performed.
*
* @return  <CODE>true</CODE> if the user's password must be changed before any other operation can be performed.
*/
  public boolean mustChangePassword()
  {
    if(mustChangePassword != ConditionResult.UNDEFINED)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning stored result of %b for user %s.",
            mustChangePassword == ConditionResult.TRUE, userDNString);
      }

      return mustChangePassword == ConditionResult.TRUE;
    }

    // If the password policy doesn't use force change on add or force change on reset, or if it forbids the user
    // from changing his password, then return false.
    // FIXME: the only getter responsible for a state attribute (pwdReset) that considers the policy before
    // checking the entry for the presence of the attribute.
    if (!passwordPolicy.isAllowUserPasswordChanges()
        || (!passwordPolicy.isForceChangeOnAdd() && !passwordPolicy.isForceChangeOnReset()))
    {
      mustChangePassword = ConditionResult.FALSE;
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false for user %s because neither force change on add nor force change on reset" +
                " is enabled, or users are not allowed to self-modify passwords.", userDNString);
      }

      return false;
    }

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_RESET_REQUIRED_LC, OP_ATTR_PWPOLICY_RESET_REQUIRED);
    try
    {
      mustChangePassword = getBoolean(userEntry, type);
    }
    catch (Exception e)
    {
      logger.traceException(e, "Returning true for user %s because an error occurred", userDNString);

      mustChangePassword = ConditionResult.TRUE;

      return true;
    }

    if(mustChangePassword == ConditionResult.UNDEFINED)
    {
      mustChangePassword = ConditionResult.FALSE;
      logger.trace("Returning %b for user since the attribute \"%s\" is not present in the entry.",
          false, userDNString, OP_ATTR_PWPOLICY_RESET_REQUIRED);

      return false;
    }

    final boolean result = mustChangePassword == ConditionResult.TRUE;
    logger.trace("Returning %b for user %s.", result, userDNString);
    return result;
  }



/**
* Updates the user entry to indicate whether the user's password must be changed.
*
* @param  mustChangePassword  Indicates whether the user's password must be changed.
*/
  public void setMustChangePassword(boolean mustChangePassword)
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Updating user %s to set the reset flag to %b", userDNString, mustChangePassword);
    }

    if (mustChangePassword == mustChangePassword())
    {
      return;  // requested state matches current state
    }

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_RESET_REQUIRED_LC, OP_ATTR_PWPOLICY_RESET_REQUIRED);
    this.mustChangePassword = ConditionResult.not(this.mustChangePassword);
    if (mustChangePassword)
    {
      Attribute a = Attributes.create(type, String.valueOf(true));
      modifications.add(new Modification(ModificationType.REPLACE, a, true));
    }
    else
    {
      modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(type), true));
    }
  }


  /**
   * Indicates whether the user's account is locked because the password has been reset by an administrator
   * but the user did not change the password in a timely manner.
   *
   * @return  <CODE>true</CODE> if the user's account is locked because of the maximum reset age,
   *          or <CODE>false</CODE> if not.
   */
  public boolean lockedDueToMaximumResetAge()
  {
    // This feature is responsible for neither a state field nor an entry state attribute.
    if (passwordPolicy.getMaxPasswordResetAge() <= 0L)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false for user %s because there is no maximum reset age.", userDNString);
      }

      return false;
    }

    if (! mustChangePassword())
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false for user %s because the user's password has not been reset.", userDNString);
      }

      return false;
    }

    long maxResetTime = getPasswordChangedTime() + 1000L * passwordPolicy.getMaxPasswordResetAge();
    boolean locked = maxResetTime < currentTime;

    if (logger.isTraceEnabled())
    {
      logger.trace("Returning %b for user %s after comparing the current and max reset times.", locked, userDNString);
    }

    return locked;
  }

  /**
   * Returns whether the account was locked for any reason.
   *
   * @return true if the account is locked, false otherwise
   */
  public boolean isLocked()
  {
    return lockedDueToIdleInterval() || lockedDueToMaximumResetAge() || lockedDueToFailures();
  }

  /**
   * Retrieves the time that the user's password should expire (if the expiration is in the future) or
   * did expire (if the expiration was in the past).  Note that this method should be called after the
   * <CODE>lockedDueToMaximumResetAge</CODE> method because grace logins will not be allowed in the case
   * that the maximum reset age has passed whereas they may be used for expiration due to maximum password
   * age or forced change time.
   *
   * @return  The time that the user's password should/did expire, or -1 if it should not expire.
   */
  public long getPasswordExpirationTime()
  {
    if (passwordExpirationTime == Long.MIN_VALUE)
    {
      passwordExpirationTime = Long.MAX_VALUE;

      boolean checkWarning = false;

      long maxAge = passwordPolicy.getMaxPasswordAge();
      if (maxAge > 0L)
      {
        long expTime = getPasswordChangedTime() + 1000L * maxAge;
        if (expTime < passwordExpirationTime)
        {
          passwordExpirationTime = expTime;
          checkWarning   = true;
        }
      }

      long maxResetAge = passwordPolicy.getMaxPasswordResetAge();
      if (mustChangePassword() && maxResetAge > 0L)
      {
        long expTime = getPasswordChangedTime() + 1000L * maxResetAge;
        if (expTime < passwordExpirationTime)
        {
          passwordExpirationTime = expTime;
          checkWarning   = false;
        }
      }

      long mustChangeTime = passwordPolicy.getRequireChangeByTime();
      if (mustChangeTime > 0)
      {
        long reqChangeTime = getRequiredChangeTime();
        if (reqChangeTime != mustChangeTime && mustChangeTime < passwordExpirationTime)
        {
          passwordExpirationTime = mustChangeTime;
          checkWarning   = true;
        }
      }

      if (passwordExpirationTime == Long.MAX_VALUE)
      {
        passwordExpirationTime = -1;
        shouldWarn             = ConditionResult.FALSE;
        isFirstWarning         = ConditionResult.FALSE;
        isPasswordExpired      = ConditionResult.FALSE;
        mayUseGraceLogin       = ConditionResult.TRUE;
      }
      else if (checkWarning)
      {
        mayUseGraceLogin = ConditionResult.TRUE;

        long warningInterval = passwordPolicy.getPasswordExpirationWarningInterval();
        if (warningInterval > 0L)
        {
          long shouldWarnTime = passwordExpirationTime - warningInterval * 1000L;
          if (shouldWarnTime > currentTime)
          {
            // The warning time is in the future, so we know the password isn't expired.
            shouldWarn        = ConditionResult.FALSE;
            isFirstWarning    = ConditionResult.FALSE;
            isPasswordExpired = ConditionResult.FALSE;
          }
          else
          {
            // We're at least in the warning period, but the password may be expired.
            long theWarnedTime = getWarnedTime();

            if (passwordExpirationTime > currentTime)
            {
              // The password is not expired but we should warn the user.
              shouldWarn        = ConditionResult.TRUE;
              isPasswordExpired = ConditionResult.FALSE;

              if (theWarnedTime < 0)
              {
                isFirstWarning = ConditionResult.TRUE;
                setWarnedTime();

                if (! passwordPolicy.isExpirePasswordsWithoutWarning())
                {
                  passwordExpirationTime = currentTime + warningInterval * 1000L;
                }
              }
              else
              {
                isFirstWarning = ConditionResult.FALSE;

                if (! passwordPolicy.isExpirePasswordsWithoutWarning())
                {
                  passwordExpirationTime = theWarnedTime + warningInterval * 1000L;
                }
              }
            }
            else
            {
              // The expiration time has passed, but we may not actually be expired if the user has not
              // yet seen a warning.
              if (passwordPolicy.isExpirePasswordsWithoutWarning())
              {
                shouldWarn        = ConditionResult.FALSE;
                isFirstWarning    = ConditionResult.FALSE;
                isPasswordExpired = ConditionResult.TRUE;
              }
              else if (theWarnedTime > 0)
              {
                passwordExpirationTime = theWarnedTime + warningInterval*1000L;
                if (passwordExpirationTime > currentTime)
                {
                  shouldWarn        = ConditionResult.TRUE;
                  isFirstWarning    = ConditionResult.FALSE;
                  isPasswordExpired = ConditionResult.FALSE;
                }
                else
                {
                  shouldWarn        = ConditionResult.FALSE;
                  isFirstWarning    = ConditionResult.FALSE;
                  isPasswordExpired = ConditionResult.TRUE;
                }
              }
              else
              {
                shouldWarn             = ConditionResult.TRUE;
                isFirstWarning         = ConditionResult.TRUE;
                isPasswordExpired      = ConditionResult.FALSE;
                passwordExpirationTime = currentTime + warningInterval*1000L;
              }
            }
          }
        }
        else
        {
          // There will never be a warning, and the user's password may be expired.
          shouldWarn     = ConditionResult.FALSE;
          isFirstWarning = ConditionResult.FALSE;

          if (currentTime > passwordExpirationTime)
          {
            isPasswordExpired = ConditionResult.TRUE;
          }
          else
          {
            isPasswordExpired = ConditionResult.FALSE;
          }
        }
      }
      else
      {
        mayUseGraceLogin = ConditionResult.FALSE;
        shouldWarn       = ConditionResult.FALSE;
        isFirstWarning   = ConditionResult.FALSE;

        if (passwordExpirationTime < currentTime)
        {
          isPasswordExpired = ConditionResult.TRUE;
        }
        else
        {
          isPasswordExpired = ConditionResult.FALSE;
        }
      }
    }

    if (logger.isTraceEnabled())
    {
      logger.trace("Returning password expiration time of %d for user %s.", passwordExpirationTime, userDNString);
    }

    return passwordExpirationTime;
  }



  /**
   * Indicates whether the user's password is currently expired.
   *
   * @return  <CODE>true</CODE> if the user's password is currently expired, or <CODE>false</CODE> if not.
   */
  public boolean isPasswordExpired()
  {
    refreshIfUndefined(isPasswordExpired);
    return isPasswordExpired == ConditionResult.TRUE;
  }

  private void refreshIfUndefined(ConditionResult cond)
  {
    if (cond == null || cond == ConditionResult.UNDEFINED)
    {
      getPasswordExpirationTime();
    }
  }

  /**
   * Indicates whether the user's last password change was within the minimum password age.
   *
   * @return  <CODE>true</CODE> if the password minimum age is nonzero, the account is not in force-change mode,
   *          and the last password change was within the minimum age, or <CODE>false</CODE> otherwise.
   */
  public boolean isWithinMinimumAge()
  {
    // This feature is responsible for neither a state field nor entry state attribute.
    long minAge = passwordPolicy.getMinPasswordAge();
    if (minAge <= 0L)
    {
      // There is no minimum age, so the user isn't in it.
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false because there is no minimum age.");
      }

      return false;
    }
    else if (getPasswordChangedTime() + minAge * 1000L < currentTime)
    {
      // It's been long enough since the user changed their password.
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false because the minimum age has expired.");
      }

      return false;
    }
    else if (mustChangePassword())
    {
      // The user is in a must-change mode, so the minimum age doesn't apply.
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false because the account is in a must-change state.");
      }

      return false;
    }
    else
    {
      // The user is within the minimum age.
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning true.");
      }

      return true;
    }
  }



  /**
   * Indicates whether the user may use a grace login if the password is expired and there is at least one
   * grace login remaining.  Note that this does not check to see if the user's password is expired, does not
   * verify that there are any remaining grace logins, and does not update the set of grace login times.
   *
   * @return  <CODE>true</CODE> if the user may use a grace login if the password is expired and there is
   *          at least one grace login remaining, or <CODE>false</CODE> if the user may not use a grace
   *          login for some reason.
   */
  public boolean mayUseGraceLogin()
  {
    refreshIfUndefined(mayUseGraceLogin);
    return mayUseGraceLogin == ConditionResult.TRUE;
  }



  /**
   * Indicates whether the user should receive a warning notification that the password is about to expire.
   *
   * @return  <CODE>true</CODE> if the user should receive a warning notification that the password is about to expire,
   *          or <CODE>false</CODE> if not.
   */
  public boolean shouldWarn()
  {
    refreshIfUndefined(shouldWarn);
    return shouldWarn == ConditionResult.TRUE;
  }



  /**
   * Indicates whether the warning that the user should receive would be the first warning for the user.
   *
   * @return  <CODE>true</CODE> if the warning that should be sent to the user would be the first warning,
   *          or <CODE>false</CODE> if not.
   */
  public boolean isFirstWarning()
  {
    refreshIfUndefined(isFirstWarning);
    return isFirstWarning == ConditionResult.TRUE;
  }



  /**
   * Retrieves the length of time in seconds until the user's password expires.
   *
   * @return  The length of time in seconds until the user's password expires,
   *          0 if the password is currently expired, or -1 if the password should not expire.
   */
  public int getSecondsUntilExpiration()
  {
    long expirationTime = getPasswordExpirationTime();
    if (expirationTime < 0)
    {
      return -1;
    }
    else if (expirationTime < currentTime)
    {
      return 0;
    }
    else
    {
      return (int) ((expirationTime - currentTime) / 1000);
    }
  }



  /**
   * Retrieves the timestamp for the last required change time that the user complied with.
   *
   * @return  The timestamp for the last required change time that the user complied with,
   *          or -1 if the user's password has not been changed in compliance with this configuration.
   */
  public long getRequiredChangeTime()
  {
    if (requiredChangeTime != Long.MIN_VALUE)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning stored required change time of %d for user %s", requiredChangeTime, userDNString);
      }

      return requiredChangeTime;
    }

    try
    {
      requiredChangeTime = getGeneralizedTime0(userEntry, OP_ATTR_PWPOLICY_CHANGED_BY_REQUIRED_TIME);
    }
    catch (Exception e)
    {
      logger.traceException(e, "Returning %d for user %s because an error occurred", requiredChangeTime, userDNString);

      requiredChangeTime = -1;
      return requiredChangeTime;
    }

    logger.trace("Returning required change time of %d for user %s", requiredChangeTime, userDNString);

    return requiredChangeTime;
  }



  /**
   * Updates the user entry with a timestamp indicating that the password has been changed in accordance
   * with the require change time.
   */
  public void setRequiredChangeTime()
  {
    long requiredChangeByTimePolicy = passwordPolicy.getRequireChangeByTime();
    if (requiredChangeByTimePolicy > 0)
    {
      setRequiredChangeTime(requiredChangeByTimePolicy);
    }
  }



  /**
   * Updates the user entry with a timestamp indicating that the password has been changed in accordance
   * with the require change time.
   *
   * @param  requiredChangeTime  The timestamp to use for the required change time value.
   */
  public void setRequiredChangeTime(long requiredChangeTime)
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Updating required change time for user %s", userDNString);
    }

    if (getRequiredChangeTime() != requiredChangeTime)
    {
      this.requiredChangeTime = requiredChangeTime;

      String timeValue = GeneralizedTimeSyntax.format(requiredChangeTime);
      Attribute a = Attributes.create(OP_ATTR_PWPOLICY_CHANGED_BY_REQUIRED_TIME, timeValue);
      modifications.add(new Modification(ModificationType.REPLACE, a, true));
    }
  }



  /**
   * Updates the user entry to remove any timestamp indicating that the password has been changed in accordance
   * with the required change time.
   */
  public void clearRequiredChangeTime()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Clearing required change time for user %s", userDNString);
    }

    this.requiredChangeTime = Long.MIN_VALUE;

    String attrName = OP_ATTR_PWPOLICY_CHANGED_BY_REQUIRED_TIME;
    modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(attrName), true));
  }


  /**
   * Retrieves the time that the user was first warned about an upcoming expiration.
   *
   * @return  The time that the user was first warned about an upcoming expiration, or -1 if the user has
   *          not been warned.
   */
  public long getWarnedTime()
  {
    if (warnedTime == Long.MIN_VALUE)
    {
      try
      {
        warnedTime = getGeneralizedTime0(userEntry, OP_ATTR_PWPOLICY_WARNED_TIME);
      }
      catch (Exception e)
      {
        logger.traceException(e, "Unable to decode the warned time for user %s", userDNString);
        warnedTime = -1;
      }
    }

    logger.trace("Returning a warned time of %d for user %s", warnedTime, userDNString);
    return warnedTime;
  }



  /**
   * Updates the user entry to set the warned time to the current time.
   */
  public void setWarnedTime()
  {
    setWarnedTime(currentTime);
  }



  /**
   * Updates the user entry to set the warned time to the specified time.  This method should generally
   * only be used for testing purposes, since the variant that uses the current time is preferred almost
   * everywhere else.
   *
   * @param  warnedTime  The value to use for the warned time.
   */
  public void setWarnedTime(long warnedTime)
  {
    long warnTime = getWarnedTime();
    if (warnTime == warnedTime)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Not updating warned time for user %s because the warned time is the same as the specified time.",
            userDNString);
      }

      return;
    }

    this.warnedTime = warnedTime;

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(OP_ATTR_PWPOLICY_WARNED_TIME);
    Attribute a = Attributes.create(type, GeneralizedTimeSyntax.createGeneralizedTimeValue(currentTime));

    modifications.add(new Modification(ModificationType.REPLACE, a, true));

    if (logger.isTraceEnabled())
    {
      logger.trace("Updated the warned time for user %s", userDNString);
    }
  }



  /**
   * Updates the user entry to clear the warned time.
   */
  public void clearWarnedTime()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Clearing warned time for user %s", userDNString);
    }

    if (getWarnedTime() < 0)
    {
      return;
    }
    warnedTime = -1;

    String attrName = OP_ATTR_PWPOLICY_WARNED_TIME;
    modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(attrName), true));

    if (logger.isTraceEnabled())
    {
      logger.trace("Cleared the warned time for user %s", userDNString);
    }
  }



  /**
   * Retrieves the times that the user has authenticated to the server using a grace login.
   *
   * @return  The times that the user has authenticated to the server using a grace login.
   */
  public List<Long> getGraceLoginTimes()
  {
    if (graceLoginTimes == null)
    {
      AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_GRACE_LOGIN_TIME_LC, OP_ATTR_PWPOLICY_GRACE_LOGIN_TIME);
      try
      {
        graceLoginTimes = getGeneralizedTimes(type);
      }
      catch (Exception e)
      {
        logger.traceException(e, "Error while processing grace login times for user %s", userDNString);

        graceLoginTimes = new ArrayList<>();

        modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(type), true));
      }
    }

    logger.trace("Returning grace login times for user %s", userDNString);
    return graceLoginTimes;
  }



  /**
   * Retrieves the number of grace logins that the user has left.
   *
   * @return  The number of grace logins that the user has left, or -1 if grace logins are not allowed.
   */
  public int getGraceLoginsRemaining()
  {
    int maxGraceLogins = passwordPolicy.getGraceLoginCount();
    if (maxGraceLogins <= 0)
    {
      return -1;
    }

    List<Long> theGraceLoginTimes = getGraceLoginTimes();
    return maxGraceLogins - theGraceLoginTimes.size();
  }



  /**
   * Updates the set of grace login times for the user to include the current time.
   */
  public void updateGraceLoginTimes()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Updating grace login times for user %s", userDNString);
    }

    List<Long> graceTimes = getGraceLoginTimes();
    long highestGraceTime = computeHighestTime(graceTimes);
    graceTimes.add(highestGraceTime); // graceTimes == this.graceLoginTimes

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_GRACE_LOGIN_TIME_LC, OP_ATTR_PWPOLICY_GRACE_LOGIN_TIME);
    Attribute addAttr = Attributes.create(type, GeneralizedTimeSyntax.format(highestGraceTime));
    modifications.add(new Modification(ModificationType.ADD, addAttr, true));
  }

  private long computeHighestTime(List<Long> graceTimes)
  {
    long highestTime = -1;
    for (long l : graceTimes)
    {
      highestTime = Math.max(l, highestTime);
    }

    if (highestTime >= currentTime)
    {
      highestTime++;
    }
    else
    {
      highestTime = currentTime;
    }
    return highestTime;
  }



  /**
   * Specifies the set of grace login use times for the associated user.  If the provided list is empty
   * or {@code null}, then the set will be cleared.
   *
   * @param  graceLoginTimes  The grace login use times for the associated user.
   */
  public void setGraceLoginTimes(List<Long> graceLoginTimes)
  {
    if (graceLoginTimes == null || graceLoginTimes.isEmpty())
    {
      clearGraceLoginTimes();
      return;
    }

    if (logger.isTraceEnabled())
    {
      logger.trace("Updating grace login times for user %s", userDNString);
    }

    this.graceLoginTimes = graceLoginTimes;

    AttributeBuilder builder = new AttributeBuilder(OP_ATTR_PWPOLICY_GRACE_LOGIN_TIME_LC);
    for (long l : graceLoginTimes)
    {
      builder.add(GeneralizedTimeSyntax.format(l));
    }
    Attribute a = builder.toAttribute();

    modifications.add(new Modification(ModificationType.REPLACE, a, true));
  }



  /**
   * Updates the user entry to remove any record of previous grace logins.
   */
  public void clearGraceLoginTimes()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Clearing grace login times for user %s", userDNString);
    }

    List<Long> graceTimes = getGraceLoginTimes();
    if (graceTimes.isEmpty())
    {
      return;
    }
    graceTimes.clear(); // graceTimes == this.graceLoginTimes

    AttributeType type = DirectoryServer.getAttributeTypeOrDefault(
        OP_ATTR_PWPOLICY_GRACE_LOGIN_TIME_LC, OP_ATTR_PWPOLICY_GRACE_LOGIN_TIME);
    modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(type), true));
  }


  /**
   * Retrieves a list of the clear-text passwords for the user.  If the user does not have any passwords
   * in the clear, then the list will be empty.
   *
   * @return  A list of the clear-text passwords for the user.
   */
  public List<ByteString> getClearPasswords()
  {
    LinkedList<ByteString> clearPasswords = new LinkedList<>();

    List<Attribute> attrList = userEntry.getAttribute(passwordPolicy.getPasswordAttribute());

    if (attrList == null)
    {
      return clearPasswords;
    }

    for (Attribute a : attrList)
    {
      for (ByteString v : a)
      {
        try
        {
          String[] pwComponents = getPwComponents(v);

          String schemeName = pwComponents[0];
          PasswordStorageScheme<?> scheme = getPasswordStorageScheme(schemeName);
          if (scheme == null)
          {
            if (logger.isTraceEnabled())
            {
              logger.trace("User entry %s contains a password with scheme %s that is not defined in the server.",
                  userDNString, schemeName);
            }

            continue;
          }

          if (scheme.isReversible())
          {
            clearPasswords.add(getPlaintextValue(scheme, pwComponents));
          }
        }
        catch (Exception e)
        {
          logger.traceException(e);

          if (logger.isTraceEnabled())
          {
            logger.trace("Cannot get clear password value for user %s: %s", userDNString, e);
          }
        }
      }
    }

    return clearPasswords;
  }

  private ByteString getPlaintextValue(PasswordStorageScheme<?> scheme, String[] pwComponents)
      throws DirectoryException
  {
    return passwordPolicy.isAuthPasswordSyntax()
        ? scheme.getAuthPasswordPlaintextValue(pwComponents[1], pwComponents[2])
        : scheme.getPlaintextValue(ByteString.valueOfUtf8(pwComponents[1]));
  }

  @Override
  public boolean passwordMatches(ByteString password)
  {
    List<Attribute> attrList = userEntry.getAttribute(passwordPolicy.getPasswordAttribute());
    if (attrList == null || attrList.isEmpty())
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false because user %s does not have any values for password attribute %s",
            userDNString, passwordPolicy.getPasswordAttribute().getNameOrOID());
      }

      return false;
    }

    for (Attribute a : attrList)
    {
      for (ByteString v : a)
      {
        try
        {
          String[] pwComponents = getPwComponents(v);
          String schemeName = pwComponents[0];
          PasswordStorageScheme<?> scheme = getPasswordStorageScheme(schemeName);
          if (scheme == null)
          {
            if (logger.isTraceEnabled())
            {
              logger.trace("User entry %s contains a password with scheme %s that is not defined in the server.",
                                  userDNString, schemeName);
            }

            continue;
          }

          if (passwordMatches(password, pwComponents, scheme))
          {
            if (logger.isTraceEnabled())
            {
              logger.trace("Returning true for user %s because the provided password matches a value " +
                      "encoded with scheme %s", userDNString, schemeName);
            }

            return true;
          }
        }
        catch (Exception e)
        {
          logger.traceException(e, "An error occurred while attempting to process a password value for user %s",
              userDNString);
        }
      }
    }

    // If we've gotten here, then we couldn't find a match.
    logger.trace("Returning false because the provided password does not match any of the stored password " +
            "values for user %s", userDNString);

    return false;
  }


  /**
   * Get the broken-down components of the given password value.
   *
   * @param  usesAuthPasswordSyntax  true if the value is an authPassword.
   * @param  v  The encoded password value to break down.
   *
   * @return An array of components.
   */
  private String[] getPwComponents(ByteString v) throws DirectoryException
  {
    return passwordPolicy.isAuthPasswordSyntax()
        ? AuthPasswordSyntax.decodeAuthPassword(v.toString())
        : UserPasswordSyntax.decodeUserPassword(v.toString());
  }

  /**
   * Indicates whether the provided password value is pre-encoded.
   *
   * @param  passwordValue  The value for which to make the determination.
   *
   * @return  <CODE>true</CODE> if the provided password value is pre-encoded, or <CODE>false</CODE> if it is not.
   */
  public boolean passwordIsPreEncoded(ByteString passwordValue)
  {
    return passwordPolicy.isAuthPasswordSyntax()
        ? AuthPasswordSyntax.isEncoded(passwordValue)
        : UserPasswordSyntax.isEncoded(passwordValue);
  }



  /**
   * Encodes the provided password using the default storage schemes (using the appropriate syntax for the
   * password attribute).
   *
   * @param  password  The password to be encoded.
   *
   * @return  The password encoded using the default schemes.
   *
   * @throws  DirectoryException  If a problem occurs while attempting to encode the password.
   */
  public List<ByteString> encodePassword(ByteString password)
         throws DirectoryException
  {
    List<PasswordStorageScheme<?>> schemes = passwordPolicy.getDefaultPasswordStorageSchemes();
    List<ByteString> encodedPasswords = new ArrayList<>(schemes.size());

    if (passwordPolicy.isAuthPasswordSyntax())
    {
      for (PasswordStorageScheme<?> s : schemes)
      {
        encodedPasswords.add(s.encodeAuthPassword(password));
      }
    }
    else
    {
      for (PasswordStorageScheme<?> s : schemes)
      {
        encodedPasswords.add(s.encodePasswordWithScheme(password));
      }
    }

    return encodedPasswords;
  }



  /**
   * Indicates whether the provided password appears to be acceptable according to the password validators.
   *
   * @param  operation         The operation that provided the password.
   * @param  userEntry         The user entry in which the password is used.
   * @param  newPassword       The password to be validated.
   * @param  currentPasswords  The set of clear-text current passwords for the user (this may be a subset
   *                           if not all of them are available in the clear, or empty if none of them
   *                           are available in the clear).
   * @param  invalidReason     A buffer that may be used to hold the invalid reason if the password is rejected.
   *
   * @return  <CODE>true</CODE> if the password is acceptable for use, or <CODE>false</CODE> if it is not.
   */
  public boolean passwordIsAcceptable(Operation operation, Entry userEntry, ByteString newPassword,
                                      Set<ByteString> currentPasswords, LocalizableMessageBuilder invalidReason)
  {
    for (PasswordValidator<?> validator : passwordPolicy.getPasswordValidators())
    {
      if (!validator.passwordIsAcceptable(newPassword, currentPasswords, operation, userEntry, invalidReason))
      {
        if (logger.isTraceEnabled())
        {
          logger.trace("The password provided for user %s failed validation: %s", userDNString, invalidReason);
        }
        return false;
      }
    }
    return true;
  }



  /**
   * Performs any processing that may be necessary to remove deprecated storage schemes from the user's entry
   * that match the provided password and re-encodes them using the default schemes.
   *
   * @param  password  The clear-text password provided by the user.
   */
  public void handleDeprecatedStorageSchemes(ByteString password)
  {
    if (passwordPolicy.getDeprecatedPasswordStorageSchemes().isEmpty())
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Doing nothing for user %s because no deprecated storage schemes have been defined.",
            userDNString);
      }

      return;
    }


    AttributeType type = passwordPolicy.getPasswordAttribute();
    List<Attribute> attrList = userEntry.getAttribute(type);
    if (attrList == null || attrList.isEmpty())
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Doing nothing for entry %s because no password values were found.", userDNString);
      }

      return;
    }


    HashSet<String> existingDefaultSchemes = new HashSet<>();
    LinkedHashSet<ByteString> removedValues = new LinkedHashSet<>();
    LinkedHashSet<ByteString> updatedValues = new LinkedHashSet<>();

    for (Attribute a : attrList)
    {
      for (ByteString v : a) {
        try {
          String[] pwComponents = getPwComponents(v);

          String schemeName = pwComponents[0];
          PasswordStorageScheme<?> scheme = getPasswordStorageScheme(schemeName);
          if (scheme == null) {
            if (logger.isTraceEnabled()) {
              logger.trace("Skipping password value for user %s because the associated storage scheme %s " +
                  "is not configured for use.", userDNString, schemeName);
            }
            continue;
          }

          if (passwordMatches(password, pwComponents, scheme))
          {
            if (passwordPolicy.isDefaultPasswordStorageScheme(schemeName)) {
              existingDefaultSchemes.add(schemeName);
              updatedValues.add(v);
            } else if (passwordPolicy.isDeprecatedPasswordStorageScheme(schemeName)) {
              if (logger.isTraceEnabled()) {
                logger.trace("Marking password with scheme %s for removal from user entry %s.",
                    schemeName, userDNString);
              }
              removedValues.add(v);
            } else {
              updatedValues.add(v);
            }
          }
        } catch (Exception e) {
          logger.traceException(e, "Skipping password value for user %s because an error occurred while attempting " +
              "to decode it based on the user password syntax", userDNString);
        }
      }
    }

    if (removedValues.isEmpty())
    {
      logger.trace("User entry %s does not have any password values encoded using deprecated schemes.", userDNString);
      return;
    }

    LinkedHashSet<ByteString> addedValues = new LinkedHashSet<>();
    for (PasswordStorageScheme<?> s : passwordPolicy.getDefaultPasswordStorageSchemes())
    {
      if (! existingDefaultSchemes.contains(toLowerCase(s.getStorageSchemeName())))
      {
        try
        {
          ByteString encodedPassword = encodePassword(password, s);
          addedValues.add(encodedPassword);
          updatedValues.add(encodedPassword);
        }
        catch (Exception e)
        {
          logger.traceException(e);

          if (logger.isTraceEnabled())
          {
            logger.traceException(e, "Unable to encode password for user %s using default scheme %s",
                userDNString, s.getStorageSchemeName());
          }
        }
      }
    }

    if (updatedValues.isEmpty())
    {
      logger.trace(
          "Not updating user entry %s because removing deprecated schemes would leave the user without a password.",
          userDNString);
      return;
    }

    Attribute a = newAttribute(type, removedValues);
    modifications.add(new Modification(ModificationType.DELETE, a, true));

    if (! addedValues.isEmpty())
    {
      Attribute a2 = newAttribute(type, addedValues);
      modifications.add(new Modification(ModificationType.ADD, a2, true));
    }

    if (logger.isTraceEnabled())
    {
      logger.trace("Updating user entry %s to replace password values encoded with deprecated schemes " +
          "with values encoded with the default schemes.", userDNString);
    }
  }

  private PasswordStorageScheme<?> getPasswordStorageScheme(String schemeName)
  {
    return passwordPolicy.isAuthPasswordSyntax()
        ? DirectoryServer.getAuthPasswordStorageScheme(schemeName)
        : DirectoryServer.getPasswordStorageScheme(schemeName);
  }

  private boolean passwordMatches(ByteString password, String[] pwComponents, PasswordStorageScheme<?> scheme)
  {
    return passwordPolicy.isAuthPasswordSyntax()
        ? scheme.authPasswordMatches(password, pwComponents[1], pwComponents[2])
        : scheme.passwordMatches(password, ByteString.valueOfUtf8(pwComponents[1]));
  }

  private ByteString encodePassword(ByteString password, PasswordStorageScheme<?> s) throws DirectoryException
  {
    return passwordPolicy.isAuthPasswordSyntax()
        ? s.encodeAuthPassword(password)
        : s.encodePasswordWithScheme(password);
  }

  /**
   * Indicates whether password history information should be maintained for this user.
   *
   * @return  {@code true} if password history information should be maintained for this user, or {@code false} if not.
   */
  public boolean maintainHistory()
  {
    return passwordPolicy.getPasswordHistoryCount() > 0
        || passwordPolicy.getPasswordHistoryDuration() > 0;
  }



  /**
   * Indicates whether the provided password is equal to any of the current passwords,
   * or any of the passwords in the history.
   *
   * @param  password  The password for which to make the determination.
   *
   * @return  {@code true} if the provided password is equal to any of the current passwords or any of the passwords
   *          in the history, or {@code false} if not.
   */
  public boolean isPasswordInHistory(ByteString password)
  {
    if (! maintainHistory())
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning false because password history checking is disabled.");
      }
      return false;
    }

    // Check to see if the provided password is equal to any of the current passwords.
    // If so, then we'll consider it to be in the history.
    if (passwordMatches(password))
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Returning true because the provided password is currently in use.");
      }
      return true;
    }

    // Get the attribute containing the history and check to see if any of the values is equal to the provided password.
    // However, first prune the list by size and duration if necessary.
    TreeMap<Long, ByteString> historyMap = getSortedHistoryValues(null);

    int historyCount = passwordPolicy.getPasswordHistoryCount();
    if (historyCount > 0 && historyMap.size() > historyCount)
    {
      int numToDelete = historyMap.size() - historyCount;
      Iterator<Long> iterator = historyMap.keySet().iterator();
      while (iterator.hasNext() && numToDelete > 0)
      {
        iterator.next();
        iterator.remove();
        numToDelete--;
      }
    }

    long historyDuration = passwordPolicy.getPasswordHistoryDuration();
    if (historyDuration > 0L)
    {
      long retainDate = currentTime - 1000 * historyDuration;
      Iterator<Long> iterator = historyMap.keySet().iterator();
      while (iterator.hasNext())
      {
        long historyDate = iterator.next();
        if (historyDate >= retainDate)
        {
          break;
        }
        iterator.remove();
      }
    }

    for (ByteString v : historyMap.values())
    {
      if (historyValueMatches(password, v))
      {
        if (logger.isTraceEnabled())
        {
          logger.trace("Returning true because the password is in the history.");
        }

        return true;
      }
    }

    // If we've gotten here, then the password isn't in the history.
    if (logger.isTraceEnabled())
    {
      logger.trace("Returning false because the password isn't in the history.");
    }
    return false;
  }



  /**
   * Gets a sorted list of the password history values contained in the user's entry.
   * The values will be sorted by timestamp.
   *
   * @param  removeAttrs  A list into which any values will be placed that could not be properly decoded.
   *                      It may be {@code null} if this is not needed.
   */
  private TreeMap<Long,ByteString> getSortedHistoryValues(List<Attribute> removeAttrs)
  {
    TreeMap<Long, ByteString> historyMap = new TreeMap<>();
    AttributeType historyType = DirectoryServer.getAttributeTypeOrDefault(OP_ATTR_PWPOLICY_HISTORY_LC);
    List<Attribute> attrList = userEntry.getAttribute(historyType);
    if (attrList != null)
    {
      for (Attribute a : attrList)
      {
        for (ByteString v : a)
        {
          String histStr = v.toString();
          int    hashPos = histStr.indexOf('#');
          if (hashPos <= 0)
          {
            if (logger.isTraceEnabled())
            {
              logger.trace("Found value " + histStr + " in the history with no timestamp.  Marking it for removal.");
            }

            if (removeAttrs != null)
            {
              removeAttrs.add(Attributes.create(a.getAttributeType(), v));
            }
          }
          else
          {
            try
            {
              ByteString timeValue = ByteString.valueOfUtf8(histStr.substring(0, hashPos));
              long timestamp = GeneralizedTimeSyntax.decodeGeneralizedTimeValue(timeValue);
              historyMap.put(timestamp, v);
            }
            catch (Exception e)
            {
              if (logger.isTraceEnabled())
              {
                logger.traceException(e);
                logger.trace("Could not decode the timestamp in history value " + histStr + " -- " + e +
                    ".  Marking it for removal.");
              }

              if (removeAttrs != null)
              {
                removeAttrs.add(Attributes.create(a.getAttributeType(), v));
              }
            }
          }
        }
      }
    }

    return historyMap;
  }



  /**
   * Indicates whether the provided password matches the given history value.
   *
   * @param  password      The clear-text password for which to make the determination.
   * @param  historyValue  The encoded history value to compare against the clear-text password.
   *
   * @return  {@code true} if the provided password matches the history value, or {@code false} if not.
   */
  private boolean historyValueMatches(ByteString password, ByteString historyValue) {
    // According to draft-behera-ldap-password-policy, password history values should be in the format
    // time#syntaxoid#encodedvalue.  In this method, we only care about the syntax OID and encoded password.
    try
    {
      String histStr  = historyValue.toString();
      int    hashPos1 = histStr.indexOf('#');
      if (hashPos1 <= 0)
      {
        if (logger.isTraceEnabled())
        {
          logger.trace("Returning false because the password history value didn't include any hash characters.");
        }

        return false;
      }

      int hashPos2 = histStr.indexOf('#', hashPos1+1);
      if (hashPos2 < 0)
      {
        if (logger.isTraceEnabled())
        {
          logger.trace("Returning false because the password history value only had one hash character.");
        }

        return false;
      }

      String syntaxOID = toLowerCase(histStr.substring(hashPos1+1, hashPos2));
      if (SYNTAX_AUTH_PASSWORD_OID.equals(syntaxOID))
      {
        return logResult("auth", encodedAuthPasswordMatches(password, histStr.substring(hashPos2+1)));
      }
      else if (SYNTAX_USER_PASSWORD_OID.equals(syntaxOID))
      {
        return logResult("user", encodedUserPasswordMatches(password, histStr.substring(hashPos2+1)));
      }
      else
      {
        if (logger.isTraceEnabled())
        {
          logger.trace("Returning false because the syntax OID " + syntaxOID +
              " didn't match for either the auth or user password syntax.");
        }

        return false;
      }
    }
    catch (Exception e)
    {
      if (logger.isTraceEnabled())
      {
        logger.traceException(e);
        logger.trace("Returning false because of an exception:  " + stackTraceToSingleLineString(e));
      }

      return false;
    }
  }

  private boolean encodedAuthPasswordMatches(ByteString password, String encodedAuthPassword) throws DirectoryException
  {
    String[] authPWComponents = AuthPasswordSyntax.decodeAuthPassword(encodedAuthPassword);
    PasswordStorageScheme<?> scheme = DirectoryServer.getAuthPasswordStorageScheme(authPWComponents[0]);
    return scheme.authPasswordMatches(password, authPWComponents[1], authPWComponents[2]);
  }

  private boolean encodedUserPasswordMatches(ByteString password, String encodedUserPassword) throws DirectoryException
  {
    String[] userPWComponents = UserPasswordSyntax.decodeUserPassword(encodedUserPassword);
    PasswordStorageScheme<?> scheme = DirectoryServer.getPasswordStorageScheme(userPWComponents[0]);
    return scheme.passwordMatches(password, ByteString.valueOfUtf8(userPWComponents[1]));
  }

  private boolean logResult(String passwordType, boolean passwordMatches)
  {
    if (passwordMatches)
    {
      logger.trace("Returning true because the %s password history value matched.", passwordType);
      return true;
    }
    else
    {
      logger.trace("Returning false because the %s password history value did not match.", passwordType);
      return false;
    }
  }

  /**
   * Updates the password history information for this user by adding one of the passwords to it.
   * It will choose the first password encoded using a secure storage scheme, and will fall back to
   * a password encoded using an insecure storage scheme if necessary.
   */
  public void updatePasswordHistory()
  {
    List<Attribute> attrList = userEntry.getAttribute(passwordPolicy.getPasswordAttribute());
    if (attrList != null)
    {
      for (Attribute a : attrList)
      {
        ByteString insecurePassword = null;
        for (ByteString v : a)
        {
          try
          {
            PasswordStorageScheme<?> scheme = getPasswordStorageScheme(v);

            if (scheme.isStorageSchemeSecure())
            {
              addPasswordToHistory(v.toString());
              insecurePassword = null;
              // no need to check any more values for this attribute
              break;
            }
            else if (insecurePassword == null)
            {
              insecurePassword = v;
            }
          }
          catch (DirectoryException e)
          {
            if (logger.isTraceEnabled())
            {
              logger.trace("Encoded password " + v + " cannot be decoded and cannot be added to history.");
            }
          }
        }
        // If we get here we haven't found a password encoded securely, so we have to use one of the other values.
        if (insecurePassword != null)
        {
          addPasswordToHistory(insecurePassword.toString());
        }
      }
    }
  }



  /**
   * Adds the provided password to the password history.  If appropriate, one or more old passwords may be
   * evicted from the list if the total size would exceed the configured count, or if passwords are older
   * than the configured duration.
   *
   * @param  encodedPassword  The encoded password (in either user password or auth password format)
   *                          to be added to the history.
   */
  private void addPasswordToHistory(String encodedPassword)
  {
    if (! maintainHistory())
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Not doing anything because password history maintenance is disabled.");
      }

      return;
    }


    // Get a sorted list of the existing values to see if there are any that should be removed.
    LinkedList<Attribute> removeAttrs = new LinkedList<>();
    TreeMap<Long, ByteString> historyMap = getSortedHistoryValues(removeAttrs);


    // If there is a maximum number of values to retain and we would be over the limit with the new value,
    // then get rid of enough values (oldest first) to satisfy the count.
    AttributeType historyType = DirectoryServer.getAttributeTypeOrDefault(OP_ATTR_PWPOLICY_HISTORY_LC);
    int historyCount = passwordPolicy.getPasswordHistoryCount();
    if  (historyCount > 0 && historyMap.size() >= historyCount)
    {
      int numToDelete = historyMap.size() - historyCount + 1;
      LinkedHashSet<ByteString> removeValues = new LinkedHashSet<>(numToDelete);
      Iterator<ByteString> iterator = historyMap.values().iterator();
      while (iterator.hasNext() && numToDelete > 0)
      {
        ByteString v = iterator.next();
        removeValues.add(v);
        iterator.remove();
        numToDelete--;

        if (logger.isTraceEnabled())
        {
          logger.trace("Removing history value %s to preserve the history count.", v);
        }
      }

      if (! removeValues.isEmpty())
      {
        removeAttrs.add(newAttribute(historyType, removeValues));
      }
    }


    // If there is a maximum duration, then get rid of any values that would be over the duration.
    long historyDuration = passwordPolicy.getPasswordHistoryDuration();
    if (historyDuration > 0L)
    {
      long minAgeToKeep = currentTime - 1000L * historyDuration;
      Iterator<Long> iterator = historyMap.keySet().iterator();
      LinkedHashSet<ByteString> removeValues = new LinkedHashSet<>();
      while (iterator.hasNext())
      {
        long timestamp = iterator.next();
        if (timestamp >= minAgeToKeep)
        {
          break;
        }

        ByteString v = historyMap.get(timestamp);
        removeValues.add(v);
        iterator.remove();

        if (logger.isTraceEnabled())
        {
          logger.trace("Removing history value %s to preserve the history duration.", v);
        }
      }

      if (! removeValues.isEmpty())
      {
        removeAttrs.add(newAttribute(historyType, removeValues));
      }
    }


    // At this point, we can add the new value.  However, we want to make sure that its timestamp
    // (which is the current time) doesn't conflict with any value already in the list.  If there is a conflict,
    // then simply add one to it until we don't have any more conflicts.
    long newTimestamp = currentTime;
    while (historyMap.containsKey(newTimestamp))
    {
      newTimestamp++;
    }
    String newHistStr = GeneralizedTimeSyntax.format(newTimestamp) + "#" +
        passwordPolicy.getPasswordAttribute().getSyntax().getOID() + "#" + encodedPassword;
    Attribute newHistAttr = Attributes.create(historyType, newHistStr);

    if (logger.isTraceEnabled())
    {
      logger.trace("Going to add history value " + newHistStr);
    }


    // Apply the changes, either by adding modifications or by directly updating the entry.
    for (Attribute a : removeAttrs)
    {
      modifications.add(new Modification(ModificationType.DELETE, a, true));
    }

    modifications.add(new Modification(ModificationType.ADD, newHistAttr, true));
  }

  private Attribute newAttribute(AttributeType type, LinkedHashSet<ByteString> values)
  {
    AttributeBuilder builder = new AttributeBuilder(type);
    builder.addAll(values);
    return builder.toAttribute();
  }

  /**
   * Retrieves the password history state values for the user.  This is only intended for testing purposes.
   *
   * @return  The password history state values for the user.
   */
  public String[] getPasswordHistoryValues()
  {
    ArrayList<String> historyValues = new ArrayList<>();
    AttributeType historyType = DirectoryServer.getAttributeTypeOrDefault(OP_ATTR_PWPOLICY_HISTORY_LC);
    List<Attribute> attrList = userEntry.getAttribute(historyType);
    if (attrList != null)
    {
      for (Attribute a : attrList)
      {
        for (ByteString v : a)
        {
          historyValues.add(v.toString());
        }
      }
    }

    return historyValues.toArray(new String[historyValues.size()]);
  }



  /**
   * Clears the password history state information for the user.  This is only intended for testing purposes.
   */
  public void clearPasswordHistory()
  {
    if (logger.isTraceEnabled())
    {
      logger.trace("Clearing password history for user %s", userDNString);
    }

    modifications.add(new Modification(ModificationType.REPLACE, Attributes.empty(OP_ATTR_PWPOLICY_HISTORY_LC), true));
  }


  /**
   * Generates a new password for the user.
   *
   * @return  The new password that has been generated, or <CODE>null</CODE> if no password generator has been defined.
   *
   * @throws  DirectoryException  If an error occurs while attempting to generate the new password.
   */
  public ByteString generatePassword()
      throws DirectoryException
  {
    PasswordGenerator<?> generator = passwordPolicy.getPasswordGenerator();
    if (generator == null)
    {
      if (logger.isTraceEnabled())
      {
        logger.trace("Unable to generate a new password for user %s because no password generator has been defined" +
            "in the associated password policy.", userDNString);
      }

      return null;
    }

    return generator.generatePassword(userEntry);
  }



  /**
   * Generates an account status notification for this user.
   *
   * @param  notificationType        The type for the account status notification.
   * @param  userEntry               The entry for the user to which this notification applies.
   * @param  message                 The human-readable message for the notification.
   * @param  notificationProperties  The set of properties for the notification.
   */
  public void generateAccountStatusNotification(
      AccountStatusNotificationType notificationType,
      Entry userEntry, LocalizableMessage message,
      Map<AccountStatusNotificationProperty,List<String>> notificationProperties)
  {
    generateAccountStatusNotification(
        new AccountStatusNotification(notificationType, userEntry, message, notificationProperties));
  }



  /**
   * Generates an account status notification for this user.
   *
   * @param  notification  The account status notification that should be generated.
   */
  public void generateAccountStatusNotification(AccountStatusNotification notification)
  {
    Collection<AccountStatusNotificationHandler<?>> handlers = passwordPolicy.getAccountStatusNotificationHandlers();
    for (AccountStatusNotificationHandler<?> handler : handlers)
    {
      handler.handleStatusNotification(notification);
    }
  }



  /**
   * Retrieves the set of modifications that correspond to changes made in password policy processing
   * that may need to be applied to the user entry.
   *
   * @return  The set of modifications that correspond to changes made in password policy processing
   *          that may need to be applied to the user entry.
   */
  public List<Modification> getModifications()
  {
    return modifications;
  }



  @Override
  public void finalizeStateAfterBind()
         throws DirectoryException
  {
    // If there are no modifications, then there's nothing to do.
    if (modifications.isEmpty())
    {
      return;
    }

    // Convert the set of modifications to a set of LDAP modifications.
    ArrayList<RawModification> modList = new ArrayList<>();
    for (Modification m : modifications)
    {
      modList.add(RawModification.create(m.getModificationType(), new LDAPAttribute(m.getAttribute())));
    }

    InternalClientConnection conn = getRootConnection();
    ModifyOperation internalModify = conn.processModify(ByteString.valueOfUtf8(userDNString), modList);

    ResultCode resultCode = internalModify.getResultCode();
    if (resultCode != ResultCode.SUCCESS)
    {
      LocalizableMessage message = ERR_PWPSTATE_CANNOT_UPDATE_USER_ENTRY.get(
          userDNString, internalModify.getErrorMessage());

      // If this is a root user, or if the password policy says that we should ignore these problems,
      // then log a warning message.  Otherwise, cause the bind to fail.
      if (DirectoryServer.isRootDN(userEntry.getName())
          || passwordPolicy.getStateUpdateFailurePolicy() == PasswordPolicyCfgDefn.StateUpdateFailurePolicy.IGNORE)
      {
        logger.error(message);
      }
      else
      {
        throw new DirectoryException(resultCode, message);
      }
    }
  }
}
