/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 * 
 *    https://www.apache.org/licenses/LICENSE-2.0
 * 
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 * 
 */
package org.apache.directory.api.ldap.model.entry;


import java.text.ParseException;
import java.util.Arrays;
import java.util.Iterator;

import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attributes;
import javax.naming.directory.BasicAttribute;
import javax.naming.directory.BasicAttributes;

import org.apache.directory.api.i18n.I18n;
import org.apache.directory.api.ldap.model.exception.LdapException;
import org.apache.directory.api.ldap.model.exception.LdapInvalidAttributeTypeException;
import org.apache.directory.api.ldap.model.exception.LdapInvalidAttributeValueException;
import org.apache.directory.api.ldap.model.name.Dn;
import org.apache.directory.api.util.Chars;
import org.apache.directory.api.util.Position;
import org.apache.directory.api.util.Strings;


/**
 * A set of utility fuctions for working with Attributes.
 * 
 * @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
 */
public final class AttributeUtils
{
    private AttributeUtils()
    {
    }


    /**
     * Check if an attribute contains a value. The test is case insensitive,
     * and the value is supposed to be a String. If the value is a byte[],
     * then the case sensitivity is useless.
     *
     * @param attr The attribute to check
     * @param value The value to look for
     * @return true if the value is present in the attribute
     */
    public static boolean containsValueCaseIgnore( javax.naming.directory.Attribute attr, Object value )
    {
        // quick bypass test
        if ( attr.contains( value ) )
        {
            return true;
        }

        try
        {
            if ( value instanceof String )
            {
                String strVal = ( String ) value;

                NamingEnumeration<?> attrVals = attr.getAll();

                while ( attrVals.hasMoreElements() )
                {
                    Object attrVal = attrVals.nextElement();

                    if ( attrVal instanceof String && strVal.equalsIgnoreCase( ( String ) attrVal ) )
                    {
                        return true;
                    }
                }
            }
            else
            {
                byte[] valueBytes = ( byte[] ) value;

                NamingEnumeration<?> attrVals = attr.getAll();

                while ( attrVals.hasMoreElements() )
                {
                    Object attrVal = attrVals.nextElement();

                    if ( attrVal instanceof byte[] && Arrays.equals( ( byte[] ) attrVal, valueBytes ) )
                    {
                        return true;
                    }
                }
            }
        }
        catch ( NamingException ne )
        {
            return false;
        }

        return false;
    }


    /**
     * Check if the attributes is a BasicAttributes, and if so, switch
     * the case sensitivity to false to avoid tricky problems in the server.
     * (Ldap attributeTypes are *always* case insensitive)
     * 
     * @param attributes The Attributes to check
     * @return The modified Attributes
     */
    public static Attributes toCaseInsensitive( Attributes attributes )
    {
        if ( attributes == null )
        {
            return attributes;
        }

        if ( attributes instanceof BasicAttributes )
        {
            if ( attributes.isCaseIgnored() )
            {
                // Just do nothing if the Attributes is already case insensitive
                return attributes;
            }
            else
            {
                // Ok, bad news : we have to create a new BasicAttributes
                // which will be case insensitive
                Attributes newAttrs = new BasicAttributes( true );

                NamingEnumeration<?> attrs = attributes.getAll();

                if ( attrs != null )
                {
                    // Iterate through the attributes now
                    while ( attrs.hasMoreElements() )
                    {
                        newAttrs.put( ( javax.naming.directory.Attribute ) attrs.nextElement() );
                    }
                }

                return newAttrs;
            }
        }
        else
        {
            // we can safely return the attributes if it's not a BasicAttributes
            return attributes;
        }
    }


    /**
     * Parse attribute's options :
     * 
     * <pre>
     * options = *( ';' option )
     * option = 1*keychar
     * keychar = 'a'-z' | 'A'-'Z' / '0'-'9' / '-'
     * </pre>
     * 
     * @param str The parsed option
     * @param pos The position in the parsed option string
     * @exception ParseException The parsed option is invalid
     */
    private static void parseOptions( char[] str, Position pos ) throws ParseException
    {
        while ( Strings.isCharASCII( str, pos.start, ';' ) )
        {
            pos.start++;

            // We have an option
            if ( !Chars.isAlphaDigitMinus( str, pos.start ) )
            {
                // We must have at least one keychar
                throw new ParseException( I18n.err( I18n.ERR_13201_EMPTY_OPTION_NOT_ALLOWED ), pos.start );
            }

            pos.start++;

            while ( Chars.isAlphaDigitMinus( str, pos.start ) )
            {
                pos.start++;
            }
        }
    }




    /**
     * Parse attribute's options :
     * 
     * <pre>
     * options = *( ';' option )
     * option = 1*keychar
     * keychar = 'a'-z' | 'A'-'Z' / '0'-'9' / '-'
     * </pre>
     * 
     * @param bytes The parsed option
     * @param pos The position in the parsed option bytes
     * @exception ParseException The parsed option is invalid
     */
    private static void parseOptions( byte[] bytes, Position pos ) throws ParseException
    {
        while ( Strings.isCharASCII( bytes, pos.start, ';' ) )
        {
            pos.start++;

            // We have an option
            if ( !Chars.isAlphaDigitMinus( bytes, pos.start ) )
            {
                // We must have at least one keychar
                throw new ParseException( I18n.err( I18n.ERR_13201_EMPTY_OPTION_NOT_ALLOWED ), pos.start );
            }

            pos.start++;

            while ( Chars.isAlphaDigitMinus( bytes, pos.start ) )
            {
                pos.start++;
            }
        }
    }


    /**
     * Parse a number :
     * 
     * <pre>
     * number = '0' | '1'..'9' digits
     * digits = '0'..'9'*
     * </pre>
     * 
     * @param filter The number in the filter
     * @param pos The position in the parsed filter string
     * @return true if a number has been found
     */
    private static boolean parseNumber( char[] filter, Position pos )
    {
        char c = Strings.charAt( filter, pos.start );

        switch ( c )
        {
            case '0':
                // If we get a starting '0', we should get out
                pos.start++;
                return true;

            case '1':
            case '2':
            case '3':
            case '4':
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
                pos.start++;
                break;

            default:
                // Not a number.
                return false;
        }

        while ( Chars.isDigit( filter, pos.start ) )
        {
            pos.start++;
        }

        return true;
    }


    /**
     * Parse a number :
     * 
     * <pre>
     * number = '0' | '1'..'9' digits
     * digits = '0'..'9'*
     * </pre>
     * 
     * @param bytes The parsed number
     * @param pos The position in the parsed number string
     * @return true if a number has been found
     */
    private static boolean parseNumber( byte[] bytes, Position pos )
    {
        byte b = Strings.byteAt( bytes, pos.start );

        switch ( b )
        {
            case '0':
                // If we get a starting '0', we should get out
                pos.start++;
                return true;

            case '1':
            case '2':
            case '3':
            case '4':
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
                pos.start++;
                break;

            default:
                // Not a number.
                return false;
        }

        while ( Chars.isDigit( bytes, pos.start ) )
        {
            pos.start++;
        }

        return true;
    }


    /**
     * Parse an OID.
     *
     * numericoid = number 1*( '.' number )
     * number = '0'-'9' / ( '1'-'9' 1*'0'-'9' )
     *
     * @param str The OID to parse
     * @param pos The current position in the string
     * @throws ParseException If we don't have a valid OID
     */
    private static void parseOID( char[] str, Position pos ) throws ParseException
    {
        // We have an OID
        parseNumber( str, pos );

        // We must have at least one '.' number
        if ( !Strings.isCharASCII( str, pos.start, '.' ) )
        {
            throw new ParseException( I18n.err( I18n.ERR_13221_INVALID_OID_MISSING_DOT ), pos.start );
        }

        pos.start++;

        if ( !parseNumber( str, pos ) )
        {
            throw new ParseException( I18n.err( I18n.ERR_13202_INVALID_OID_MISSING_NUMBER ), pos.start );
        }

        while ( true )
        {
            // Break if we get something which is not a '.'
            if ( !Strings.isCharASCII( str, pos.start, '.' ) )
            {
                break;
            }

            pos.start++;

            if ( !parseNumber( str, pos ) )
            {
                throw new ParseException( I18n.err( I18n.ERR_13202_INVALID_OID_MISSING_NUMBER ), pos.start );
            }
        }
    }




    /**
     * Parse an OID.
     *
     * numericoid = number 1*( '.' number )
     * number = '0'-'9' / ( '1'-'9' 1*'0'-'9' )
     *
     * @param bytes The OID to parse
     * @param pos The current position in the string
     * @throws ParseException If we don't have a valid OID
     */
    private static void parseOID( byte[] bytes, Position pos ) throws ParseException
    {
        // We have an OID
        parseNumber( bytes, pos );

        // We must have at least one '.' number
        if ( !Strings.isCharASCII( bytes, pos.start, '.' ) )
        {
            throw new ParseException( I18n.err( I18n.ERR_13221_INVALID_OID_MISSING_DOT ), pos.start );
        }

        pos.start++;

        if ( !parseNumber( bytes, pos ) )
        {
            throw new ParseException( I18n.err( I18n.ERR_13202_INVALID_OID_MISSING_NUMBER ), pos.start );
        }

        while ( true )
        {
            // Break if we get something which is not a '.'
            if ( !Strings.isCharASCII( bytes, pos.start, '.' ) )
            {
                break;
            }

            pos.start++;

            if ( !parseNumber( bytes, pos ) )
            {
                throw new ParseException( I18n.err( I18n.ERR_13202_INVALID_OID_MISSING_NUMBER ), pos.start );
            }
        }
    }


    /**
     * Parse an attribute. The grammar is :
     * attributedescription = attributetype options
     * attributetype = oid
     * oid = descr / numericoid
     * descr = keystring
     * numericoid = number 1*( '.' number )
     * options = *( ';' option )
     * option = 1*keychar
     * keystring = leadkeychar *keychar
     * leadkeychar = 'a'-z' | 'A'-'Z'
     * keychar = 'a'-z' | 'A'-'Z' / '0'-'9' / '-'
     * number = '0'-'9' / ( '1'-'9' 1*'0'-'9' )
     *
     * @param str The parsed attribute,
     * @param pos The position of the attribute in the current string
     * @param withOption A flag set if we want to parse the options
     * @param relaxed A flag set if we want to parse without being too strict
     * @return The parsed attribute if valid
     * @throws ParseException If we had an issue while parsing the attribute
     */
    public static String parseAttribute( char[] str, Position pos, boolean withOption, boolean relaxed )
        throws ParseException
    {
        // We must have an OID or an DESCR first
        char c = Strings.charAt( str, pos.start );

        if ( c == '\0' )
        {
            throw new ParseException( I18n.err( I18n.ERR_13222_EMPTY_ATTRIBUTE ), pos.start );
        }

        int start = pos.start;

        if ( Chars.isAlpha( c ) )
        {
            // A DESCR
            pos.start++;

            while ( Chars.isAlphaDigitMinus( str, pos.start ) || ( relaxed && Chars.isCharASCII( str, pos.start, '_' ) ) )
            {
                pos.start++;
            }

            // Parse the options if needed
            if ( withOption )
            {
                parseOptions( str, pos );
            }

            return new String( str, start, pos.start - start );
        }
        else if ( Chars.isDigit( c ) )
        {
            // An OID
            pos.start++;

            // Parse the OID
            parseOID( str, pos );

            // Parse the options
            if ( withOption )
            {
                parseOptions( str, pos );
            }

            return new String( str,  start, pos.start - start );
        }
        else
        {
            throw new ParseException( I18n.err( I18n.ERR_13223_BAD_CHAR_IN_ATTRIBUTE ), pos.start );
        }
    }




    /**
     * Parse an attribute. The grammar is :
     * attributedescription = attributetype options
     * attributetype = oid
     * oid = descr / numericoid
     * descr = keystring
     * numericoid = number 1*( '.' number )
     * options = *( ';' option )
     * option = 1*keychar
     * keystring = leadkeychar *keychar
     * leadkeychar = 'a'-z' | 'A'-'Z'
     * keychar = 'a'-z' | 'A'-'Z' / '0'-'9' / '-'
     * number = '0'-'9' / ( '1'-'9' 1*'0'-'9' )
     *
     * @param bytes The parsed attribute,
     * @param pos The position of the attribute in the current string
     * @param withOption A flag set if we want to parse the options
     * @param relaxed A flag set if we want to parse without being too strict
     * @return The parsed attribute if valid
     * @throws ParseException If we had an issue while parsing the attribute
     */
    public static String parseAttribute( byte[] bytes, Position pos, boolean withOption, boolean relaxed )
        throws ParseException
    {
        // We must have an OID or an DESCR first
        byte b = Strings.byteAt( bytes, pos.start );

        if ( b == '\0' )
        {
            throw new ParseException( I18n.err( I18n.ERR_13222_EMPTY_ATTRIBUTE ), pos.start );
        }

        int start = pos.start;

        if ( Chars.isAlpha( b ) )
        {
            // A DESCR
            while ( Chars.isAlphaDigitMinus( bytes, pos.start ) || ( relaxed && Strings.isCharASCII( bytes, pos.start, '_' ) ) )
            {
                pos.start++;
            }

            // Parse the options if needed
            if ( withOption )
            {
                parseOptions( bytes, pos );
            }

            return Strings.utf8ToString( bytes, start, pos.start - start );
        }
        else if ( Chars.isDigit( b ) )
        {
            // Parse the OID
            parseOID( bytes, pos );

            // Parse the options
            if ( withOption )
            {
                parseOptions( bytes, pos );
            }

            return Strings.utf8ToString( bytes, start, pos.start - start );
        }
        else
        {
            throw new ParseException( I18n.err( I18n.ERR_13223_BAD_CHAR_IN_ATTRIBUTE ), pos.start );
        }
    }


    /**
     * A method to apply a modification to an existing entry.
     * 
     * @param entry The entry on which we want to apply a modification
     * @param modification the Modification to be applied
     * @throws LdapException if some operation fails.
     */
    public static void applyModification( Entry entry, Modification modification ) throws LdapException
    {
        Attribute modAttr = modification.getAttribute();
        String modificationId = modAttr.getUpId();

        switch ( modification.getOperation() )
        {
            case ADD_ATTRIBUTE:
                Attribute modifiedAttr = entry.get( modificationId );

                if ( modifiedAttr == null )
                {
                    // The attribute should be added.
                    entry.put( modAttr );
                }
                else
                {
                    // The attribute exists : the values can be different,
                    // so we will just add the new values to the existing ones.
                    for ( Value value : modAttr )
                    {
                        // If the value already exist, nothing is done.
                        // Note that the attribute *must* have been
                        // normalized before.
                        modifiedAttr.add( value );
                    }
                }

                break;

            case REMOVE_ATTRIBUTE:
                if ( modAttr.get() == null )
                {
                    // We have no value in the ModificationItem attribute :
                    // we have to remove the whole attribute from the initial
                    // entry
                    entry.removeAttributes( modificationId );
                }
                else
                {
                    // We just have to remove the values from the original
                    // entry, if they exist.
                    modifiedAttr = entry.get( modificationId );

                    if ( modifiedAttr == null )
                    {
                        break;
                    }

                    for ( Value value : modAttr )
                    {
                        // If the value does not exist, nothing is done.
                        // Note that the attribute *must* have been
                        // normalized before.
                        modifiedAttr.remove( value );
                    }

                    if ( modifiedAttr.size() == 0 )
                    {
                        // If this was the last value, remove the attribute
                        entry.removeAttributes( modifiedAttr.getUpId() );
                    }
                }

                break;

            case REPLACE_ATTRIBUTE:
                if ( modAttr.get() == null )
                {
                    // If the modification does not have any value, we have
                    // to delete the attribute from the entry.
                    entry.removeAttributes( modificationId );
                }
                else
                {
                    // otherwise, just substitute the existing attribute.
                    entry.put( modAttr );
                }

                break;
            default:
                break;
        }
    }


    /**
     * Convert a BasicAttributes or a AttributesImpl to an Entry
     *
     * @param attributes the BasicAttributes or AttributesImpl instance to convert
     * @param dn The Dn which is needed by the Entry
     * @return An instance of a Entry object
     * 
     * @throws LdapException If we get an invalid attribute
     */
    public static Entry toEntry( Attributes attributes, Dn dn ) throws LdapException
    {
        if ( attributes instanceof BasicAttributes )
        {
            try
            {
                Entry entry = new DefaultEntry( dn );

                for ( NamingEnumeration<? extends javax.naming.directory.Attribute> attrs = attributes.getAll(); attrs
                    .hasMoreElements(); )
                {
                    javax.naming.directory.Attribute attr = attrs.nextElement();

                    Attribute entryAttribute = toApiAttribute( attr );

                    if ( entryAttribute != null )
                    {
                        entry.put( entryAttribute );
                    }
                }

                return entry;
            }
            catch ( LdapException ne )
            {
                throw new LdapInvalidAttributeTypeException( ne.getMessage(), ne );
            }
        }
        else
        {
            return null;
        }
    }


    /**
     * Converts an {@link Entry} to an {@link Attributes}.
     *
     * @param entry
     *      the {@link Entry} to convert
     * @return
     *      the equivalent {@link Attributes}
     */
    public static Attributes toAttributes( Entry entry )
    {
        if ( entry != null )
        {
            Attributes attributes = new BasicAttributes( true );

            // Looping on attributes
            for ( Iterator<Attribute> attributeIterator = entry.iterator(); attributeIterator.hasNext(); )
            {
                Attribute entryAttribute = attributeIterator.next();

                attributes.put( toJndiAttribute( entryAttribute ) );
            }

            return attributes;
        }

        return null;
    }


    /**
     * Converts an {@link Attribute} to a JNDI Attribute.
     *
     * @param attribute the {@link Attribute} to convert
     * @return the equivalent JNDI Attribute
     */
    public static javax.naming.directory.Attribute toJndiAttribute( Attribute attribute )
    {
        if ( attribute != null )
        {
            javax.naming.directory.Attribute jndiAttribute = new BasicAttribute( attribute.getUpId() );

            // Looping on values
            for ( Iterator<Value> valueIterator = attribute.iterator(); valueIterator.hasNext(); )
            {
                Value value = valueIterator.next();
                
                if ( value.isHumanReadable() )
                {
                    jndiAttribute.add( value.getString() );
                }
                else
                {
                    jndiAttribute.add( value.getBytes() );
                }
            }

            return jndiAttribute;
        }

        return null;
    }


    /**
     * Convert a JNDI Attribute to an LDAP API Attribute
     *
     * @param jndiAttribute the JNDI Attribute instance to convert
     * @return An instance of a LDAP API Attribute object
     * @throws LdapInvalidAttributeValueException If the attribute is invalid
     */
    public static Attribute toApiAttribute( javax.naming.directory.Attribute jndiAttribute )
        throws LdapInvalidAttributeValueException
    {
        if ( jndiAttribute == null )
        {
            return null;
        }

        try
        {
            Attribute attribute = new DefaultAttribute( jndiAttribute.getID() );

            for ( NamingEnumeration<?> values = jndiAttribute.getAll(); values.hasMoreElements(); )
            {
                Object value = values.nextElement();

                if ( value instanceof String )
                {
                    attribute.add( ( String ) value );
                }
                else if ( value instanceof byte[] )
                {
                    attribute.add( ( byte[] ) value );
                }
                else
                {
                    attribute.add( ( String ) null );
                }
            }

            return attribute;
        }
        catch ( NamingException ne )
        {
            return null;
        }
    }
}
