/*
 * 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-2008 Sun Microsystems, Inc.
 *      Portions Copyright 2012-2015 ForgeRock AS.
 */
package org.opends.server.types;

import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.StringTokenizer;

import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.i18n.slf4j.LocalizedLogger;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchScope;
import org.opends.server.core.DirectoryServer;

import static org.forgerock.opendj.ldap.ResultCode.*;
import static org.opends.messages.UtilityMessages.*;
import static org.opends.server.util.StaticUtils.*;

/**
 * This class defines a data structure that represents the components
 * of an LDAP URL, including the scheme, host, port, base DN,
 * attributes, scope, filter, and extensions.  It has the ability to
 * create an LDAP URL based on all of these individual components, as
 * well as parsing them from their string representations.
 */
@org.opends.server.types.PublicAPI(
     stability=org.opends.server.types.StabilityLevel.UNCOMMITTED,
     mayInstantiate=true,
     mayExtend=false,
     mayInvoke=true)
public final class LDAPURL
{
  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();

  /** The default scheme that will be used if none is provided. */
  public static final String DEFAULT_SCHEME = "ldap";
  /** The default port value that will be used if none is provided. */
  public static final int DEFAULT_PORT = 389;
  /** The default base DN that will be used if none is provided. */
  public static final DN DEFAULT_BASE_DN = DN.rootDN();
  /** The default search scope that will be used if none is provided. */
  public static final SearchScope DEFAULT_SEARCH_SCOPE =
       SearchScope.BASE_OBJECT;
  /** The default search filter that will be used if none is provided. */
  public static final SearchFilter DEFAULT_SEARCH_FILTER =
       SearchFilter.createPresenceFilter(
            DirectoryServer.getObjectClassAttributeType());


  /** The host for this LDAP URL. */
  private String host;
  /** The port number for this LDAP URL. */
  private int port;
  /** The base DN for this LDAP URL. */
  private DN baseDN;
  /** The raw base DN for this LDAP URL. */
  private String rawBaseDN;
  /** The search scope for this LDAP URL. */
  private SearchScope scope;
  /** The search filter for this LDAP URL. */
  private SearchFilter filter;
  /** The raw filter for this LDAP URL. */
  private String rawFilter;

  /** The set of attributes for this LDAP URL. */
  private LinkedHashSet<String> attributes;
  /** The set of extensions for this LDAP URL. */
  private LinkedList<String> extensions;


  /** The scheme (i.e., protocol) for this LDAP URL. */
  private String scheme;



  /**
   * Creates a new LDAP URL with the provided information.
   *
   * @param  scheme      The scheme (i.e., protocol) for this LDAP
   *                     URL.
   * @param  host        The address for this LDAP URL.
   * @param  port        The port number for this LDAP URL.
   * @param  rawBaseDN   The raw base DN for this LDAP URL.
   * @param  attributes  The set of requested attributes for this LDAP
   *                     URL.
   * @param  scope       The search scope for this LDAP URL.
   * @param  rawFilter   The string representation of the search
   *                     filter for this LDAP URL.
   * @param  extensions  The set of extensions for this LDAP URL.
   */
  public LDAPURL(String scheme, String host, int port,
                 String rawBaseDN, LinkedHashSet<String> attributes,
                 SearchScope scope, String rawFilter,
                 LinkedList<String> extensions)
  {
    this.host = toLowerCase(host);

    baseDN = null;
    filter = null;


    if (scheme == null)
    {
      this.scheme = "ldap";
    }
    else
    {
      this.scheme = toLowerCase(scheme);
    }

    this.port = toPort(port);

    if (rawBaseDN == null)
    {
      this.rawBaseDN = "";
    }
    else
    {
      this.rawBaseDN = rawBaseDN;
    }

    if (attributes == null)
    {
      this.attributes = new LinkedHashSet<>();
    }
    else
    {
      this.attributes = attributes;
    }

    if (scope == null)
    {
      this.scope = DEFAULT_SEARCH_SCOPE;
    }
    else
    {
      this.scope = scope;
    }

    if (rawFilter != null)
    {
      this.rawFilter = rawFilter;
    }
    else
    {
      setFilter(SearchFilter.objectClassPresent());
    }

    if (extensions == null)
    {
      this.extensions = new LinkedList<>();
    }
    else
    {
      this.extensions = extensions;
    }
  }



  /**
   * Creates a new LDAP URL with the provided information.
   *
   * @param  scheme      The scheme (i.e., protocol) for this LDAP
   *                     URL.
   * @param  host        The address for this LDAP URL.
   * @param  port        The port number for this LDAP URL.
   * @param  baseDN      The base DN for this LDAP URL.
   * @param  attributes  The set of requested attributes for this LDAP
   *                     URL.
   * @param  scope       The search scope for this LDAP URL.
   * @param  filter      The search filter for this LDAP URL.
   * @param  extensions  The set of extensions for this LDAP URL.
   */
  public LDAPURL(String scheme, String host, int port, DN baseDN,
                 LinkedHashSet<String> attributes, SearchScope scope,
                 SearchFilter filter, LinkedList<String> extensions)
  {
    this.host = toLowerCase(host);


    if (scheme == null)
    {
      this.scheme = "ldap";
    }
    else
    {
      this.scheme = toLowerCase(scheme);
    }

    this.port = toPort(port);

    if (baseDN == null)
    {
      this.baseDN    = DEFAULT_BASE_DN;
      this.rawBaseDN = DEFAULT_BASE_DN.toString();
    }
    else
    {
      this.baseDN    = baseDN;
      this.rawBaseDN = baseDN.toString();
    }

    if (attributes == null)
    {
      this.attributes = new LinkedHashSet<>();
    }
    else
    {
      this.attributes = attributes;
    }

    if (scope == null)
    {
      this.scope = DEFAULT_SEARCH_SCOPE;
    }
    else
    {
      this.scope = scope;
    }

    if (filter == null)
    {
      this.filter    = DEFAULT_SEARCH_FILTER;
      this.rawFilter = DEFAULT_SEARCH_FILTER.toString();
    }
    else
    {
      this.filter    = filter;
      this.rawFilter = filter.toString();
    }

    if (extensions == null)
    {
      this.extensions = new LinkedList<>();
    }
    else
    {
      this.extensions = extensions;
    }
  }



  /**
   * Decodes the provided string as an LDAP URL.
   *
   * @param  url          The URL string to be decoded.
   * @param  fullyDecode  Indicates whether the URL should be fully
   *                      decoded (e.g., parsing the base DN and
   *                      search filter) or just leaving them in their
   *                      string representations.  The latter may be
   *                      required for client-side use.
   *
   * @return  The LDAP URL decoded from the provided string.
   *
   * @throws  DirectoryException  If a problem occurs while attempting
   *                              to decode the provided string as an
   *                              LDAP URL.
   */
  public static LDAPURL decode(String url, boolean fullyDecode)
         throws DirectoryException
  {
    // Find the "://" component, which will separate the scheme from
    // the host.
    String scheme;
    int schemeEndPos = url.indexOf("://");
    if (schemeEndPos < 0)
    {
      LocalizableMessage message = ERR_LDAPURL_NO_COLON_SLASH_SLASH.get(url);
      throw new DirectoryException(INVALID_ATTRIBUTE_SYNTAX, message);
    }
    else if (schemeEndPos == 0)
    {
      LocalizableMessage message = ERR_LDAPURL_NO_SCHEME.get(url);
      throw new DirectoryException(INVALID_ATTRIBUTE_SYNTAX, message);
    }
    else
    {
      scheme = urlDecode(url.substring(0, schemeEndPos));
      // FIXME also need to check that the scheme is actually ldap/ldaps!!
    }


    // If the "://" was the end of the URL, then we're done.
    int length = url.length();
    if (length == schemeEndPos+3)
    {
      return new LDAPURL(scheme, null, DEFAULT_PORT, DEFAULT_BASE_DN,
                         null, DEFAULT_SEARCH_SCOPE,
                         DEFAULT_SEARCH_FILTER, null);
    }


    // Look at the next character.  If it's anything but a slash, then
    // it should be part of the host and optional port.
    String host     = null;
    int    port     = DEFAULT_PORT;
    int    startPos = schemeEndPos + 3;
    int    pos      = startPos;
    while (pos < length)
    {
      char c = url.charAt(pos);
      if (c == '/')
      {
        break;
      }
      pos++;
    }

    if (pos > startPos)
    {
      String hostPort = url.substring(startPos, pos);
      int colonPos = hostPort.lastIndexOf(':');
      if (colonPos < 0)
      {
        host = urlDecode(hostPort);
      }
      else if (colonPos == 0)
      {
        LocalizableMessage message = ERR_LDAPURL_NO_HOST.get(url);
        throw new DirectoryException(INVALID_ATTRIBUTE_SYNTAX, message);
      }
      else if (colonPos == (hostPort.length() - 1))
      {
        LocalizableMessage message = ERR_LDAPURL_NO_PORT.get(url);
        throw new DirectoryException(INVALID_ATTRIBUTE_SYNTAX, message);
      }
      else
      {
        try
        {
          final HostPort hp = HostPort.valueOf(hostPort);
          host = urlDecode(hp.getHost());
          port = hp.getPort();
        }
        catch (NumberFormatException e)
        {
          LocalizableMessage message = ERR_LDAPURL_CANNOT_DECODE_PORT.get(
              url, hostPort.substring(colonPos+1));
          throw new DirectoryException(INVALID_ATTRIBUTE_SYNTAX, message);
        }
        catch (IllegalArgumentException e)
        {
          LocalizableMessage message = ERR_LDAPURL_INVALID_PORT.get(url, port);
          throw new DirectoryException(INVALID_ATTRIBUTE_SYNTAX, message);
        }
      }
    }


    // Move past the slash.  If we're at or past the end of the
    // string, then we're done.
    pos++;
    if (pos > length)
    {
      return new LDAPURL(scheme, host, port, DEFAULT_BASE_DN, null,
                         DEFAULT_SEARCH_SCOPE, DEFAULT_SEARCH_FILTER,
                         null);
    }
    else
    {
      startPos = pos;
    }


    // The next delimiter should be a question mark.  If there isn't
    // one, then the rest of the value must be the base DN.
    String baseDNString = null;
    pos = url.indexOf('?', startPos);
    if (pos < 0)
    {
      baseDNString = urlDecode(url.substring(startPos));
      startPos = length;
    }
    else
    {
      baseDNString = urlDecode(url.substring(startPos, pos));
      startPos = pos+1;
    }

    DN baseDN;
    if (fullyDecode)
    {
      baseDN = DN.valueOf(baseDNString);
    }
    else
    {
      baseDN = null;
    }


    if (startPos >= length)
    {
      if (fullyDecode)
      {
        return new LDAPURL(scheme, host, port, baseDN, null,
                           DEFAULT_SEARCH_SCOPE,
                           DEFAULT_SEARCH_FILTER, null);
      }
      else
      {
        return new LDAPURL(scheme, host, port, baseDNString, null,
                           DEFAULT_SEARCH_SCOPE, null, null);
      }
    }


    // Find the next question mark (or the end of the string if there
    // aren't any more) and get the attribute list from it.
    String attrsString;
    pos = url.indexOf('?', startPos);
    if (pos < 0)
    {
      attrsString = url.substring(startPos);
      startPos = length;
    }
    else
    {
      attrsString = url.substring(startPos, pos);
      startPos = pos+1;
    }

    LinkedHashSet<String> attributes = new LinkedHashSet<>();
    StringTokenizer tokenizer = new StringTokenizer(attrsString, ",");
    while (tokenizer.hasMoreTokens())
    {
      attributes.add(urlDecode(tokenizer.nextToken()));
    }

    if (startPos >= length)
    {
      if (fullyDecode)
      {
        return new LDAPURL(scheme, host, port, baseDN, attributes,
                           DEFAULT_SEARCH_SCOPE,
                           DEFAULT_SEARCH_FILTER, null);
      }
      else
      {
        return new LDAPURL(scheme, host, port, baseDNString,
                           attributes, DEFAULT_SEARCH_SCOPE, null,
                           null);
      }
    }


    // Find the next question mark (or the end of the string if there
    // aren't any more) and get the scope from it.
    String scopeString;
    pos = url.indexOf('?', startPos);
    if (pos < 0)
    {
      scopeString = toLowerCase(urlDecode(url.substring(startPos)));
      startPos = length;
    }
    else
    {
      scopeString =
           toLowerCase(urlDecode(url.substring(startPos, pos)));
      startPos = pos+1;
    }

    SearchScope scope;
    if (scopeString.equals(""))
    {
      scope = DEFAULT_SEARCH_SCOPE;
    }
    else if (scopeString.equals("base"))
    {
      scope = SearchScope.BASE_OBJECT;
    }
    else if (scopeString.equals("one"))
    {
      scope = SearchScope.SINGLE_LEVEL;
    }
    else if (scopeString.equals("sub"))
    {
      scope = SearchScope.WHOLE_SUBTREE;
    }
    else if (scopeString.equals("subord") ||
             scopeString.equals("subordinate"))
    {
      scope = SearchScope.SUBORDINATES;
    }
    else
    {
      LocalizableMessage message = ERR_LDAPURL_INVALID_SCOPE_STRING.get(url, scopeString);
      throw new DirectoryException(
                     ResultCode.INVALID_ATTRIBUTE_SYNTAX, message);
    }

    if (startPos >= length)
    {
      if (fullyDecode)
      {
        return new LDAPURL(scheme, host, port, baseDN, attributes,
                           scope, DEFAULT_SEARCH_FILTER, null);
      }
      else
      {
        return new LDAPURL(scheme, host, port, baseDNString,
                           attributes, scope, null, null);
      }
    }


    // Find the next question mark (or the end of the string if there
    // aren't any more) and get the filter from it.
    String filterString;
    pos = url.indexOf('?', startPos);
    if (pos < 0)
    {
      filterString = urlDecode(url.substring(startPos));
      startPos = length;
    }
    else
    {
      filterString = urlDecode(url.substring(startPos, pos));
      startPos = pos+1;
    }

    SearchFilter filter;
    if (fullyDecode)
    {
      if (filterString.equals(""))
      {
        filter = DEFAULT_SEARCH_FILTER;
      }
      else
      {
        filter = SearchFilter.createFilterFromString(filterString);
      }

      if (startPos >= length)
      {
        if (fullyDecode)
        {
          return new LDAPURL(scheme, host, port, baseDN, attributes,
                             scope, filter, null);
        }
        else
        {
          return new LDAPURL(scheme, host, port, baseDNString,
                             attributes, scope, filterString, null);
        }
      }
    }
    else
    {
      filter = null;
    }


    // The rest of the string must be the set of extensions.
    String extensionsString = url.substring(startPos);
    LinkedList<String> extensions = new LinkedList<>();
    tokenizer = new StringTokenizer(extensionsString, ",");
    while (tokenizer.hasMoreTokens())
    {
      extensions.add(urlDecode(tokenizer.nextToken()));
    }


    if (fullyDecode)
    {
      return new LDAPURL(scheme, host, port, baseDN, attributes,
                         scope, filter, extensions);
    }
    else
    {
      return new LDAPURL(scheme, host, port, baseDNString, attributes,
                         scope, filterString, extensions);
    }
  }



  /**
   * Converts the provided string to a form that has decoded "special"
   * characters that have been encoded for use in an LDAP URL.
   *
   * @param  s  The string to be decoded.
   *
   * @return  The decoded string.
   *
   * @throws  DirectoryException  If a problem occurs while attempting
   *                              to decode the contents of the
   *                              provided string.
   */
  static String urlDecode(String s) throws DirectoryException
  {
    if (s == null)
    {
      return "";
    }

    byte[] stringBytes  = getBytes(s);
    int    length       = stringBytes.length;
    byte[] decodedBytes = new byte[length];
    int    pos          = 0;

    for (int i=0; i < length; i++)
    {
      if (stringBytes[i] == '%')
      {
        // There must be at least two bytes left.  If not, then that's
        // a problem.
        if (i+2 > length)
        {
          LocalizableMessage message = ERR_LDAPURL_PERCENT_TOO_CLOSE_TO_END.get(s, i);
          throw new DirectoryException(
                        ResultCode.INVALID_ATTRIBUTE_SYNTAX, message);
        }

        byte b;
        switch (stringBytes[++i])
        {
          case '0':
            b = (byte) 0x00;
            break;
          case '1':
            b = (byte) 0x10;
            break;
          case '2':
            b = (byte) 0x20;
            break;
          case '3':
            b = (byte) 0x30;
            break;
          case '4':
            b = (byte) 0x40;
            break;
          case '5':
            b = (byte) 0x50;
            break;
          case '6':
            b = (byte) 0x60;
            break;
          case '7':
            b = (byte) 0x70;
            break;
          case '8':
            b = (byte) 0x80;
            break;
          case '9':
            b = (byte) 0x90;
            break;
          case 'a':
          case 'A':
            b = (byte) 0xA0;
            break;
          case 'b':
          case 'B':
            b = (byte) 0xB0;
            break;
          case 'c':
          case 'C':
            b = (byte) 0xC0;
            break;
          case 'd':
          case 'D':
            b = (byte) 0xD0;
            break;
          case 'e':
          case 'E':
            b = (byte) 0xE0;
            break;
          case 'f':
          case 'F':
            b = (byte) 0xF0;
            break;
          default:
            LocalizableMessage message = ERR_LDAPURL_INVALID_HEX_BYTE.get(s, i);
            throw new DirectoryException(ResultCode.INVALID_ATTRIBUTE_SYNTAX, message);
        }

        switch (stringBytes[++i])
        {
          case '0':
            break;
          case '1':
            b |= 0x01;
            break;
          case '2':
            b |= 0x02;
            break;
          case '3':
            b |= 0x03;
            break;
          case '4':
            b |= 0x04;
            break;
          case '5':
            b |= 0x05;
            break;
          case '6':
            b |= 0x06;
            break;
          case '7':
            b |= 0x07;
            break;
          case '8':
            b |= 0x08;
            break;
          case '9':
            b |= 0x09;
            break;
          case 'a':
          case 'A':
            b |= 0x0A;
            break;
          case 'b':
          case 'B':
            b |= 0x0B;
            break;
          case 'c':
          case 'C':
            b |= 0x0C;
            break;
          case 'd':
          case 'D':
            b |= 0x0D;
            break;
          case 'e':
          case 'E':
            b |= 0x0E;
            break;
          case 'f':
          case 'F':
            b |= 0x0F;
            break;
          default:
            LocalizableMessage message = ERR_LDAPURL_INVALID_HEX_BYTE.get(s, i);
            throw new DirectoryException(ResultCode.INVALID_ATTRIBUTE_SYNTAX, message);
        }

        decodedBytes[pos++] = b;
      }
      else
      {
        decodedBytes[pos++] = stringBytes[i];
      }
    }

    try
    {
      return new String(decodedBytes, 0, pos, "UTF-8");
    }
    catch (Exception e)
    {
      logger.traceException(e);

      // This should never happen.
      LocalizableMessage message = ERR_LDAPURL_CANNOT_CREATE_UTF8_STRING.get(
          getExceptionMessage(e));
      throw new DirectoryException(
                     ResultCode.INVALID_ATTRIBUTE_SYNTAX, message);
    }
  }



  /**
   * Encodes the provided string portion for inclusion in an LDAP URL
   * and appends it to the provided buffer.
   *
   * @param  s            The string portion to be encoded.
   * @param  isExtension  Indicates whether the provided component is
   *                      an extension and therefore needs to have
   *                      commas encoded.
   * @param  buffer       The buffer to which the information should
   *                      be appended.
   */
  private static void urlEncode(String s, boolean isExtension,
                                StringBuilder buffer)
  {
    if (s == null)
    {
      return;
    }

    int length = s.length();

    for (int i=0; i < length; i++)
    {
      char c = s.charAt(i);
      if (isAlpha(c) || isDigit(c))
      {
        buffer.append(c);
        continue;
      }

      if (c == ',')
      {
        if (isExtension)
        {
          hexEncode(c, buffer);
        }
        else
        {
          buffer.append(c);
        }

        continue;
      }

      switch (c)
      {
        case '-':
        case '.':
        case '_':
        case '~':
        case ':':
        case '/':
        case '#':
        case '[':
        case ']':
        case '@':
        case '!':
        case '$':
        case '&':
        case '\'':
        case '(':
        case ')':
        case '*':
        case '+':
        case ';':
        case '=':
          buffer.append(c);
          break;
        default:
          hexEncode(c, buffer);
          break;
      }
    }
  }



  /**
   * Appends a percent-encoded representation of the provided
   * character to the given buffer.
   *
   * @param  c       The character to add to the buffer.
   * @param  buffer  The buffer to which the percent-encoded
   *                 representation should be written.
   */
  private static void hexEncode(char c, StringBuilder buffer)
  {
    if ((c & (byte) 0xFF) == c)
    {
      // It's a single byte.
      buffer.append('%');
      buffer.append(byteToHex((byte) c));
    }
    else
    {
      // It requires two bytes, and each should be prefixed by a
      // percent sign.
      buffer.append('%');
      byte b1 = (byte) ((c >>> 8) & 0xFF);
      buffer.append(byteToHex(b1));

      buffer.append('%');
      byte b2 = (byte) (c & 0xFF);
      buffer.append(byteToHex(b2));
    }
  }



  /**
   * Retrieves the scheme for this LDAP URL.
   *
   * @return  The scheme for this LDAP URL.
   */
  public String getScheme()
  {
    return scheme;
  }



  /**
   * Specifies the scheme for this LDAP URL.
   *
   * @param  scheme  The scheme for this LDAP URL.
   */
  public void setScheme(String scheme)
  {
    if (scheme == null)
    {
      this.scheme = DEFAULT_SCHEME;
    }
    else
    {
      this.scheme = scheme;
    }
  }



  /**
   * Retrieves the host for this LDAP URL.
   *
   * @return  The host for this LDAP URL, or <CODE>null</CODE> if none
   *          was provided.
   */
  public String getHost()
  {
    return host;
  }



  /**
   * Specifies the host for this LDAP URL.
   *
   * @param  host  The host for this LDAP URL.
   */
  public void setHost(String host)
  {
    this.host = host;
  }



  /**
   * Retrieves the port for this LDAP URL.
   *
   * @return  The port for this LDAP URL.
   */
  public int getPort()
  {
    return port;
  }



  /**
   * Specifies the port for this LDAP URL.
   *
   * @param  port  The port for this LDAP URL.
   */
  public void setPort(int port)
  {
    this.port = toPort(port);
  }

  private int toPort(int port)
  {
    if (0 < port && port <= 65535)
    {
      return port;
    }
    return DEFAULT_PORT;
  }

  /**
   * Retrieve the raw, unprocessed base DN for this LDAP URL.
   *
   * @return  The raw, unprocessed base DN for this LDAP URL, or
   *          <CODE>null</CODE> if none was given (in which case a
   *          default of the null DN "" should be assumed).
   */
  public String getRawBaseDN()
  {
    return rawBaseDN;
  }



  /**
   * Specifies the raw, unprocessed base DN for this LDAP URL.
   *
   * @param  rawBaseDN  The raw, unprocessed base DN for this LDAP
   *                    URL.
   */
  public void setRawBaseDN(String rawBaseDN)
  {
    this.rawBaseDN = rawBaseDN;
    this.baseDN    = null;
  }



  /**
   * Retrieves the processed DN for this LDAP URL.
   *
   * @return  The processed DN for this LDAP URL.
   *
   * @throws  DirectoryException  If the raw base DN cannot be decoded
   *                              as a valid DN.
   */
  public DN getBaseDN()
         throws DirectoryException
  {
    if (baseDN == null)
    {
      if (rawBaseDN == null || rawBaseDN.length() == 0)
      {
        return DEFAULT_BASE_DN;
      }

      baseDN = DN.valueOf(rawBaseDN);
    }

    return baseDN;
  }



  /**
   * Specifies the base DN for this LDAP URL.
   *
   * @param  baseDN  The base DN for this LDAP URL.
   */
  public void setBaseDN(DN baseDN)
  {
    if (baseDN == null)
    {
      this.baseDN    = null;
      this.rawBaseDN = null;
    }
    else
    {
      this.baseDN    = baseDN;
      this.rawBaseDN = baseDN.toString();
    }
  }



  /**
   * Retrieves the set of attributes for this LDAP URL.  The contents
   * of the returned set may be altered by the caller.
   *
   * @return  The set of attributes for this LDAP URL.
   */
  public LinkedHashSet<String> getAttributes()
  {
    return attributes;
  }



  /**
   * Retrieves the search scope for this LDAP URL.
   *
   * @return  The search scope for this LDAP URL, or <CODE>null</CODE>
   *          if none was given (in which case the base-level scope
   *          should be assumed).
   */
  public SearchScope getScope()
  {
    return scope;
  }



  /**
   * Specifies the search scope for this LDAP URL.
   *
   * @param  scope  The search scope for this LDAP URL.
   */
  public void setScope(SearchScope scope)
  {
    if (scope == null)
    {
      this.scope = DEFAULT_SEARCH_SCOPE;
    }
    else
    {
      this.scope = scope;
    }
  }



  /**
   * Retrieves the raw, unprocessed search filter for this LDAP URL.
   *
   * @return  The raw, unprocessed search filter for this LDAP URL, or
   *          <CODE>null</CODE> if none was given (in which case a
   *          default filter of "(objectClass=*)" should be assumed).
   */
  public String getRawFilter()
  {
    return rawFilter;
  }



  /**
   * Specifies the raw, unprocessed search filter for this LDAP URL.
   *
   * @param  rawFilter  The raw, unprocessed search filter for this
   *                    LDAP URL.
   */
  public void setRawFilter(String rawFilter)
  {
    this.rawFilter = rawFilter;
    this.filter    = null;
  }



  /**
   * Retrieves the processed search filter for this LDAP URL.
   *
   * @return  The processed search filter for this LDAP URL.
   *
   * @throws  DirectoryException  If a problem occurs while attempting
   *                              to decode the raw filter.
   */
  public SearchFilter getFilter()
         throws DirectoryException
  {
    if (filter == null)
    {
      if (rawFilter == null)
      {
        filter = DEFAULT_SEARCH_FILTER;
      }
      else
      {
        filter = SearchFilter.createFilterFromString(rawFilter);
      }
    }

    return filter;
  }



  /**
   * Specifies the search filter for this LDAP URL.
   *
   * @param  filter  The search filter for this LDAP URL.
   */
  public void setFilter(SearchFilter filter)
  {
    if (filter == null)
    {
      this.rawFilter = null;
      this.filter    = null;
    }
    else
    {
      this.rawFilter = filter.toString();
      this.filter    = filter;
    }
  }



  /**
   * Retrieves the set of extensions for this LDAP URL.  The contents
   * of the returned list may be altered by the caller.
   *
   * @return  The set of extensions for this LDAP URL.
   */
  public LinkedList<String> getExtensions()
  {
    return extensions;
  }



  /**
   * Indicates whether the provided entry matches the criteria defined
   * in this LDAP URL.
   *
   * @param  entry  The entry for which to make the determination.
   *
   * @return  {@code true} if the provided entry does match the
   *          criteria specified in this LDAP URL, or {@code false} if
   *          it does not.
   *
   * @throws  DirectoryException  If a problem occurs while attempting
   *                              to make the determination.
   */
  public boolean matchesEntry(Entry entry)
         throws DirectoryException
  {
    SearchScope scope = getScope();
    if (scope == null)
    {
      scope = SearchScope.BASE_OBJECT;
    }

    return entry.matchesBaseAndScope(getBaseDN(), scope)
        && getFilter().matchesEntry(entry);
  }



  /**
   * Indicates whether the provided object is equal to this LDAP URL.
   *
   * @param  o  The object for which to make the determination.
   *
   * @return  <CODE>true</CODE> if the object is equal to this LDAP
   *          URL, or <CODE>false</CODE> if not.
   */
  @Override
  public boolean equals(Object o)
  {
    if (o == this)
    {
      return true;
    }
    if (!(o instanceof LDAPURL))
    {
      return false;
    }

    LDAPURL url = (LDAPURL) o;
    return scheme.equals(url.getScheme())
        && hostEquals(url)
        && port == url.getPort()
        && baseDnsEqual(url)
        && scope.equals(url.getScope())
        && filtersEqual(url)
        && attributesEqual(url.getAttributes())
        && extensionsEqual(url.getExtensions());
  }

  private boolean hostEquals(LDAPURL url)
  {
    if (host != null)
    {
      return host.equalsIgnoreCase(url.getHost());
    }
    return url.getHost() == null;
  }

  private boolean baseDnsEqual(LDAPURL url)
  {
    try
    {
      return getBaseDN().equals(url.getBaseDN());
    }
    catch (Exception e)
    {
      logger.traceException(e);
      return Objects.equals(rawBaseDN, url.getRawBaseDN());
    }
  }

  private boolean filtersEqual(LDAPURL url)
  {
    try
    {
      return getFilter().equals(url.getFilter());
    }
    catch (Exception e)
    {
      logger.traceException(e);
      return Objects.equals(rawFilter, url.getRawFilter());
    }
  }

  private boolean attributesEqual(Set<String> urlAttrs)
  {
    if (attributes.size() != urlAttrs.size())
    {
      return false;
    }

    for (String attr : attributes)
    {
      if (!urlAttrs.contains(attr) && !containsIgnoreCase(urlAttrs, attr))
      {
        return false;
      }
    }
    return true;
  }

  private boolean containsIgnoreCase(Set<String> urlAttrs, String attr)
  {
    for (String attr2 : urlAttrs)
    {
      if (attr.equalsIgnoreCase(attr2))
      {
        return true;
      }
    }
    return false;
  }

  private boolean extensionsEqual(List<String> extensions)
  {
    if (this.extensions.size() != extensions.size())
    {
      return false;
    }

    for (String ext : this.extensions)
    {
      if (!extensions.contains(ext))
      {
        return false;
      }
    }
    return true;
  }



  /**
   * Retrieves the hash code for this LDAP URL.
   *
   * @return  The hash code for this LDAP URL.
   */
  @Override
  public int hashCode()
  {
    int hashCode = 0;

    hashCode += scheme.hashCode();

    if (host != null)
    {
      hashCode += toLowerCase(host).hashCode();
    }

    hashCode += port;

    try
    {
      hashCode += getBaseDN().hashCode();
    }
    catch (Exception e)
    {
      logger.traceException(e);

      if (rawBaseDN != null)
      {
        hashCode += rawBaseDN.hashCode();
      }
    }

    hashCode += getScope().intValue();

    for (String attr : attributes)
    {
      hashCode += toLowerCase(attr).hashCode();
    }

    try
    {
      hashCode += getFilter().hashCode();
    }
    catch (Exception e)
    {
      logger.traceException(e);

      if (rawFilter != null)
      {
        hashCode += rawFilter.hashCode();
      }
    }

    for (String ext : extensions)
    {
      hashCode += ext.hashCode();
    }

    return hashCode;
  }



  /**
   * Retrieves a string representation of this LDAP URL.
   *
   * @return  A string representation of this LDAP URL.
   */
  @Override
  public String toString()
  {
    StringBuilder buffer = new StringBuilder();
    toString(buffer, false);
    return buffer.toString();
  }



  /**
   * Appends a string representation of this LDAP URL to the provided
   * buffer.
   *
   * @param  buffer    The buffer to which the information is to be
   *                   appended.
   * @param  baseOnly  Indicates whether the resulting URL string
   *                   should only include the portion up to the base
   *                   DN, omitting the attributes, scope, filter, and
   *                   extensions.
   */
  public void toString(StringBuilder buffer, boolean baseOnly)
  {
    urlEncode(scheme, false, buffer);
    buffer.append("://");

    if (host != null)
    {
      urlEncode(host, false, buffer);
      buffer.append(":");
      buffer.append(port);
    }

    buffer.append("/");
    urlEncode(rawBaseDN, false, buffer);

    if (baseOnly)
    {
      // If there are extensions, then we need to include them.
      // Technically, we only have to include critical extensions, but
      // we'll use all of them.
      if (! extensions.isEmpty())
      {
        buffer.append("????");
        Iterator<String> iterator = extensions.iterator();
        urlEncode(iterator.next(), true, buffer);

        while (iterator.hasNext())
        {
          buffer.append(",");
          urlEncode(iterator.next(), true, buffer);
        }
      }

      return;
    }

    buffer.append("?");
    if (! attributes.isEmpty())
    {
      Iterator<String> iterator = attributes.iterator();
      urlEncode(iterator.next(), false, buffer);

      while (iterator.hasNext())
      {
        buffer.append(",");
        urlEncode(iterator.next(), false, buffer);
      }
    }

    buffer.append("?");
    switch (scope.asEnum())
    {
      case BASE_OBJECT:
        buffer.append("base");
        break;
      case SINGLE_LEVEL:
        buffer.append("one");
        break;
      case WHOLE_SUBTREE:
        buffer.append("sub");
        break;
      case SUBORDINATES:
        buffer.append("subordinate");
        break;
    }

    buffer.append("?");
    urlEncode(rawFilter, false, buffer);

    if (! extensions.isEmpty())
    {
      buffer.append("?");
      Iterator<String> iterator = extensions.iterator();
      urlEncode(iterator.next(), true, buffer);

      while (iterator.hasNext())
      {
        buffer.append(",");
        urlEncode(iterator.next(), true, buffer);
      }
    }
  }
}
