/*
 *  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
 *  
 *    http://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.studio.ldapbrowser.core.utils;


import java.beans.XMLDecoder;
import java.beans.XMLEncoder;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.text.translate.CharSequenceTranslator;
import org.apache.commons.text.translate.LookupTranslator;
import org.apache.directory.api.ldap.model.name.Ava;
import org.apache.directory.api.ldap.model.name.Dn;
import org.apache.directory.api.ldap.model.name.Rdn;
import org.apache.directory.api.ldap.model.schema.AttributeType;
import org.apache.directory.api.ldap.model.url.LdapUrl;
import org.apache.directory.api.util.Strings;
import org.apache.directory.studio.connection.core.ConnectionParameter.EncryptionMethod;
import org.apache.directory.studio.connection.core.StudioControl;
import org.apache.directory.studio.ldapbrowser.core.BrowserCoreConstants;
import org.apache.directory.studio.ldapbrowser.core.BrowserCorePlugin;
import org.apache.directory.studio.ldapbrowser.core.model.IAttribute;
import org.apache.directory.studio.ldapbrowser.core.model.IBrowserConnection;
import org.apache.directory.studio.ldapbrowser.core.model.IBrowserConnection.ModifyMode;
import org.apache.directory.studio.ldapbrowser.core.model.IBrowserConnection.ModifyOrder;
import org.apache.directory.studio.ldapbrowser.core.model.IEntry;
import org.apache.directory.studio.ldapbrowser.core.model.ISearch;
import org.apache.directory.studio.ldapbrowser.core.model.IValue;
import org.apache.directory.studio.ldapbrowser.core.model.schema.Schema;
import org.apache.directory.studio.ldapbrowser.core.model.schema.SchemaUtils;
import org.apache.directory.studio.ldifparser.LdifFormatParameters;
import org.apache.directory.studio.ldifparser.LdifUtils;
import org.apache.directory.studio.ldifparser.model.LdifFile;
import org.apache.directory.studio.ldifparser.model.container.LdifChangeModifyRecord;
import org.apache.directory.studio.ldifparser.model.container.LdifModSpec;
import org.apache.directory.studio.ldifparser.model.lines.LdifAttrValLine;
import org.apache.directory.studio.ldifparser.model.lines.LdifChangeTypeLine;
import org.apache.directory.studio.ldifparser.model.lines.LdifControlLine;
import org.apache.directory.studio.ldifparser.model.lines.LdifDnLine;
import org.apache.directory.studio.ldifparser.model.lines.LdifModSpecSepLine;
import org.apache.directory.studio.ldifparser.model.lines.LdifSepLine;
import org.eclipse.core.runtime.Preferences;


public class Utils
{

    /**
     * Transforms the given Dn into a normalized String, usable by the schema cache.
     * The following transformations are performed:
     * <ul>
     *   <li>The attribute type is replaced by the OID
     *   <li>The attribute value is trimmed and lowercased
     * </ul> 
     * Example: the surname=Bar will be transformed to
     * 2.5.4.4=bar
     * 
     * 
     * @param dn the Dn
     * @param schema the schema
     * 
     * @return the oid string
     */
    public static String getNormalizedOidString( Dn dn, Schema schema )
    {
        StringBuilder sb = new StringBuilder();

        boolean isFirst = true;
        
        for ( Rdn rdn : dn )
        {
            if ( isFirst )
            {
                isFirst = false;
            }
            else
            {
                sb.append( ',' );
            }

            sb.append( getOidString( rdn, schema ) );
        }

        return sb.toString();
    }


    private static String getOidString( Rdn rdn, Schema schema )
    {
        StringBuilder sb = new StringBuilder();

        boolean isFirst = true;
        
        for ( Ava ava : rdn )
        {
            if ( isFirst )
            {
                isFirst = false;
            }
            else
            {
                sb.append( '+' );
            }

            sb.append( getOidString( ava, schema ) );
        }

        return sb.toString();
    }


    private static String getOidString( Ava ava, Schema schema )
    {
        String oid = schema != null ? schema.getAttributeTypeDescription( ava.getNormType() ).getOid() : ava
            .getNormType();
        return Strings.toLowerCaseAscii( Strings.trim( oid ) )
            + "=" + Strings.trim( ava.getValue().getString() ).toLowerCase(); //$NON-NLS-1$
    }


    public static String arrayToString( String[] array )
    {
        if ( array == null || array.length == 0 )
        {
            return ""; //$NON-NLS-1$
        }
        else
        {
            StringBuilder sb = new StringBuilder( array[0] );
            
            for ( int i = 1; i < array.length; i++ )
            {
                sb.append( ", " ); //$NON-NLS-1$
                sb.append( array[i] );
            }
            
            return sb.toString();
        }
    }


    public static boolean equals( byte[] data1, byte[] data2 )
    {
        if ( data1 == data2 )
            return true;
        if ( data1 == null || data2 == null )
            return false;
        if ( data1.length != data2.length )
            return false;
        for ( int i = 0; i < data1.length; i++ )
        {
            if ( data1[i] != data2[i] )
                return false;
        }
        return true;
    }


    public static String getShortenedString( String value, int length )
    {
        StringBuilder sb = new StringBuilder();
        
        if ( ( value != null ) && ( value.length() > length ) )
        {
            sb.append( value.substring( 0, length ) ).append( "..." ); //$NON-NLS-1$
        }

        return sb.toString();
    }


    public static String serialize( Object o )
    {
        ClassLoader ccl = Thread.currentThread().getContextClassLoader();
        
        try
        {
            Thread.currentThread().setContextClassLoader( Utils.class.getClassLoader() );
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            XMLEncoder encoder = new XMLEncoder( baos );
            encoder.writeObject( o );
            encoder.close();

            return LdifUtils.utf8decode( baos.toByteArray() );
        }
        finally
        {
            Thread.currentThread().setContextClassLoader( ccl );
        }
    }


    public static Object deserialize( String s )
    {
        ClassLoader ccl = Thread.currentThread().getContextClassLoader();
        
        try
        {
            Thread.currentThread().setContextClassLoader( Utils.class.getClassLoader() );
            ByteArrayInputStream bais = new ByteArrayInputStream( LdifUtils.utf8encode( s ) );
            XMLDecoder decoder = new XMLDecoder( bais );
            Object o = decoder.readObject();
            decoder.close();
            
            return o;
        }
        finally
        {
            Thread.currentThread().setContextClassLoader( ccl );
        }
    }


    public static String getNonNullString( Object o )
    {
        return o == null ? "-" : o.toString(); //$NON-NLS-1$
    }


    public static String formatBytes( long bytes )
    {
        String size = ""; //$NON-NLS-1$
        
        if ( bytes > 1024 * 1024 )
        {
            size += ( bytes / 1024 / 1024 )
                + " " + Messages.Utils_MegaBytes + " (" + bytes + " " + Messages.Utils_Bytes + ")"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-6$
        }
        else if ( bytes > 1024 )
        {
            size += ( bytes / 1024 ) + " " + Messages.Utils_KiloBytes + " (" + bytes + " " + Messages.Utils_Bytes + ")"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-6$
        }
        else if ( bytes > 1 )
        {
            size += bytes + " " + Messages.Utils_Bytes; //$NON-NLS-1$
        }
        else
        {
            size += bytes + " " + Messages.Utils_Byte; //$NON-NLS-1$
        }
        
        return size;
    }


    public static boolean containsIgnoreCase( Collection<String> c, String s )
    {
        if ( c == null || s == null )
        {
            return false;
        }

        for ( String string : c )
        {
            if ( string.equalsIgnoreCase( s ) )
            {
                return true;
            }
        }

        return false;
    }


    public static LdifFormatParameters getLdifFormatParameters()
    {
        Preferences store = BrowserCorePlugin.getDefault().getPluginPreferences();
        boolean spaceAfterColon = store.getBoolean( BrowserCoreConstants.PREFERENCE_LDIF_SPACE_AFTER_COLON );
        int lineWidth = store.getInt( BrowserCoreConstants.PREFERENCE_LDIF_LINE_WIDTH );
        String lineSeparator = store.getString( BrowserCoreConstants.PREFERENCE_LDIF_LINE_SEPARATOR );

        return new LdifFormatParameters( spaceAfterColon, lineWidth, lineSeparator );
    }


    /**
     * Transforms an IBrowserConnection to an LdapURL. The following parameters are
     * used to create the LDAP URL:
     * <ul>
     * <li>scheme
     * <li>host
     * <li>port
     * </ul>
     *
     * @param entry the entry
     * @return the LDAP URL
     */
    public static LdapUrl getLdapURL( IBrowserConnection browserConnection )
    {
        LdapUrl url = new LdapUrl();
        
        if ( browserConnection.getConnection() != null )
        {
            if ( browserConnection.getConnection().getEncryptionMethod() == EncryptionMethod.LDAPS )
            {
                url.setScheme( LdapUrl.LDAPS_SCHEME );
            }
            else
            {
                url.setScheme( LdapUrl.LDAP_SCHEME );
            }
            
            url.setHost( browserConnection.getConnection().getHost() );
            url.setPort( browserConnection.getConnection().getPort() );
        }
        
        return url;
    }


    /**
     * Transforms an IEntry to an LdapURL. The following parameters are
     * used to create the LDAP URL:
     * <ul>
     * <li>scheme
     * <li>host
     * <li>port
     * <li>dn
     * </ul>
     *
     * @param entry the entry
     * @return the LDAP URL
     */
    public static LdapUrl getLdapURL( IEntry entry )
    {
        LdapUrl url = getLdapURL( entry.getBrowserConnection() );
        url.setDn( entry.getDn() );
        
        return url;
    }


    /**
     * Transforms an ISearch to an LdapURL. The following search parameters are
     * used to create the LDAP URL:
     * <ul>
     * <li>scheme
     * <li>host
     * <li>port
     * <li>search base
     * <li>returning attributes
     * <li>scope
     * <li>filter
     * </ul>
     *
     * @param search the search
     * @return the LDAP URL
     */
    public static LdapUrl getLdapURL( ISearch search )
    {
        LdapUrl url = getLdapURL( search.getBrowserConnection() );
        url.setDn( search.getSearchBase() );
        
        if ( search.getReturningAttributes() != null )
        {
            url.setAttributes( Arrays.asList( search.getReturningAttributes() ) );
        }
        
        url.setScope( search.getScope().getScope() );
        url.setFilter( search.getFilter() );
        
        return url;
    }


    /**
     * Computes the difference between the old and the new entry
     * and returns an LDIF that could be applied to the old entry
     * to get new entry.
     *
     * @param oldEntry the old entry
     * @param newEntry the new entry
     * @return the change modify record or null if there is no difference
     *         between the two entries
     */
    public static LdifFile computeDiff( IEntry oldEntry, IEntry newEntry )
    {
        // get connection parameters
        ModifyMode modifyMode = oldEntry.getBrowserConnection().getModifyMode();
        ModifyMode modifyModeNoEMR = oldEntry.getBrowserConnection().getModifyModeNoEMR();
        ModifyOrder modifyAddDeleteOrder = oldEntry.getBrowserConnection().getModifyAddDeleteOrder();

        // get all attribute descriptions
        Set<String> attributeDescriptions = new LinkedHashSet<>();
        
        for ( IAttribute oldAttr : oldEntry.getAttributes() )
        {
            attributeDescriptions.add( oldAttr.getDescription() );
        }
        
        for ( IAttribute newAttr : newEntry.getAttributes() )
        {
            attributeDescriptions.add( newAttr.getDescription() );
        }

        // prepare the LDIF record containing the modifications
        LdifChangeModifyRecord record = new LdifChangeModifyRecord( LdifDnLine.create( newEntry.getDn().getName() ) );
        
        if ( newEntry.isReferral() )
        {
            record.addControl( LdifControlLine.create( StudioControl.MANAGEDSAIT_CONTROL.getOid(),
                StudioControl.MANAGEDSAIT_CONTROL.isCritical(), StudioControl.MANAGEDSAIT_CONTROL.getControlValue() ) );
        }
        record.setChangeType( LdifChangeTypeLine.createModify() );

        // check all the attributes
        for ( String attributeDescription : attributeDescriptions )
        {
            // get attribute type schema information
            Schema schema = oldEntry.getBrowserConnection().getSchema();
            AttributeType atd = schema.getAttributeTypeDescription( attributeDescription );
            boolean hasEMR = SchemaUtils.getEqualityMatchingRuleNameOrNumericOidTransitive( atd, schema ) != null;
            boolean isReplaceForced = ( hasEMR && modifyMode == ModifyMode.REPLACE )
                || ( !hasEMR && modifyModeNoEMR == ModifyMode.REPLACE );
            boolean isAddDelForced = ( hasEMR && modifyMode == ModifyMode.ADD_DELETE )
                || ( !hasEMR && modifyModeNoEMR == ModifyMode.ADD_DELETE );
            boolean isOrderedValue = atd.getExtensions().containsKey( "X-ORDERED" ) //$NON-NLS-1$
                && atd.getExtensions().get( "X-ORDERED" ).contains( "VALUES" ); //$NON-NLS-1$ //$NON-NLS-2$

            // get old an new values for comparison
            IAttribute oldAttribute = oldEntry.getAttribute( attributeDescription );
            Set<String> oldValues = new HashSet<>();
            Map<String, LdifAttrValLine> oldAttrValLines = new LinkedHashMap<>();
            
            if ( oldAttribute != null )
            {
                for ( IValue value : oldAttribute.getValues() )
                {
                    LdifAttrValLine attrValLine = computeDiffCreateAttrValLine( value );
                    oldValues.add( attrValLine.getUnfoldedValue() );
                    oldAttrValLines.put( attrValLine.getUnfoldedValue(), attrValLine );
                }
            }
            
            IAttribute newAttribute = newEntry.getAttribute( attributeDescription );
            Set<String> newValues = new HashSet<>();
            Map<String, LdifAttrValLine> newAttrValLines = new LinkedHashMap<>();
            
            if ( newAttribute != null )
            {
                for ( IValue value : newAttribute.getValues() )
                {
                    LdifAttrValLine attrValLine = computeDiffCreateAttrValLine( value );
                    newValues.add( attrValLine.getUnfoldedValue() );
                    newAttrValLines.put( attrValLine.getUnfoldedValue(), attrValLine );
                }
            }

            // check what to do
            if ( oldAttribute != null && newAttribute == null )
            {
                // attribute only exists in the old entry: delete all values
                LdifModSpec modSpec;
                
                if ( isReplaceForced )
                {
                    // replace (empty value list)
                    modSpec = LdifModSpec.createReplace( attributeDescription );
                }
                else
                // addDelForced or default
                {
                    // delete all
                    modSpec = LdifModSpec.createDelete( attributeDescription );
                    for ( IValue value : oldAttribute.getValues() )
                    {
                        modSpec.addAttrVal( computeDiffCreateAttrValLine( value ) );
                    }
                }
                
                modSpec.finish( LdifModSpecSepLine.create() );
                record.addModSpec( modSpec );
            }
            else if ( oldAttribute == null && newAttribute != null )
            {
                // attribute only exists in the new entry: add all values
                LdifModSpec modSpec;
                
                if ( isReplaceForced )
                {
                    // replace (all values)
                    modSpec = LdifModSpec.createReplace( attributeDescription );
                }
                else
                // addDelForced or default 
                {
                    // add (all new values)
                    modSpec = LdifModSpec.createAdd( attributeDescription );
                }
                
                for ( IValue value : newAttribute.getValues() )
                {
                    modSpec.addAttrVal( computeDiffCreateAttrValLine( value ) );
                }
                
                modSpec.finish( LdifModSpecSepLine.create() );
                record.addModSpec( modSpec );
            }
            else if ( oldAttribute != null && newAttribute != null && !oldValues.equals( newValues ) )
            {
                // attribute exists in both entries, check modifications
                if ( isReplaceForced )
                {
                    // replace (all new values)
                    LdifModSpec modSpec = LdifModSpec.createReplace( attributeDescription );
                    
                    for ( IValue value : newAttribute.getValues() )
                    {
                        modSpec.addAttrVal( computeDiffCreateAttrValLine( value ) );
                    }
                    
                    modSpec.finish( LdifModSpecSepLine.create() );
                    record.addModSpec( modSpec );
                }
                else
                {
                    // compute diff
                    List<LdifAttrValLine> toDel = new ArrayList<>();
                    List<LdifAttrValLine> toAdd = new ArrayList<>();

                    for ( Map.Entry<String, LdifAttrValLine> entry : oldAttrValLines.entrySet() )
                    {
                        if ( !newValues.contains( entry.getKey() ) )
                        {
                            toDel.add( entry.getValue() );
                        }
                    }
                    
                    for ( Map.Entry<String, LdifAttrValLine> entry : newAttrValLines.entrySet() )
                    {
                        if ( !oldValues.contains( entry.getKey() ) )
                        {
                            toAdd.add( entry.getValue() );
                        }
                    }

                    /*
                     *  we use add/del in the following cases:
                     *  - add/del is forced in the connection configuration
                     *  - for attributes w/o X-ORDERED 'VALUES'
                     *  
                     *  we use replace in the following cases:
                     *  - for attributes with X-ORDERED 'VALUES'
                     */
                    if ( isAddDelForced || !isOrderedValue )
                    {
                        // add/del del/add
                        LdifModSpec addModSpec = LdifModSpec.createAdd( attributeDescription );
                        
                        for ( LdifAttrValLine attrValLine : toAdd )
                        {
                            addModSpec.addAttrVal( attrValLine );
                        }
                        
                        addModSpec.finish( LdifModSpecSepLine.create() );
                        LdifModSpec delModSpec = LdifModSpec.createDelete( attributeDescription );
                        
                        for ( LdifAttrValLine attrValLine : toDel )
                        {
                            delModSpec.addAttrVal( attrValLine );
                        }
                        
                        delModSpec.finish( LdifModSpecSepLine.create() );

                        if ( modifyAddDeleteOrder == ModifyOrder.DELETE_FIRST )
                        {
                            if ( delModSpec.getAttrVals().length > 0 )
                            {
                                record.addModSpec( delModSpec );
                            }
                            
                            if ( addModSpec.getAttrVals().length > 0 )
                            {
                                record.addModSpec( addModSpec );
                            }
                        }
                        else
                        {
                            if ( addModSpec.getAttrVals().length > 0 )
                            {
                                record.addModSpec( addModSpec );
                            }
                            
                            if ( delModSpec.getAttrVals().length > 0 )
                            {
                                record.addModSpec( delModSpec );
                            }
                        }
                    }
                    else
                    {
                        // replace (all new values)
                        LdifModSpec modSpec = LdifModSpec.createReplace( attributeDescription );
                        
                        for ( LdifAttrValLine attrValLine : newAttrValLines.values() )
                        {
                            modSpec.addAttrVal( attrValLine );
                        }
                        
                        modSpec.finish( LdifModSpecSepLine.create() );
                        record.addModSpec( modSpec );
                    }
                }
            }

        }

        record.finish( LdifSepLine.create() );

        LdifFile model = new LdifFile();
        
        if ( record.isValid() && record.getModSpecs().length > 0 )
        {
            model.addContainer( record );
        }
        
        return model.getRecords().length > 0 ? model : null;
    }


    private static LdifAttrValLine computeDiffCreateAttrValLine( IValue value )
    {
        IAttribute attribute = value.getAttribute();
        
        if ( attribute.isBinary() )
        {
            return LdifAttrValLine.create( attribute.getDescription(), value.getBinaryValue() );
        }
        else
        {
            return LdifAttrValLine.create( attribute.getDescription(), value.getStringValue() );
        }
    }


    /**
     * Decodes the RFC 4517 Postal Address syntax.
     *
     * <pre>
     * PostalAddress = line *( DOLLAR line )
     * line          = 1*line-char
     * line-char     = %x00-23
     *                 / (%x5C "24")  ; escaped "$"
     *                 / %x25-5B
     *                 / (%x5C "5C")  ; escaped "\"
     *                 / %x5D-7F
     *                 / UTFMB
     * </pre>
     *
     * @param separator the separator to output between address lines
     * @return a translator object for decoding
     */
    public static CharSequenceTranslator createPostalAddressDecoder( String separator )
    {
        return new LookupTranslator( Map.of(
            "$", separator, //$NON-NLS-1$
            "\\24", "$", //$NON-NLS-1$ //$NON-NLS-2$
            "\\5C", "\\", //$NON-NLS-1$ //$NON-NLS-2$
            "\\5c", "\\" ) ); //$NON-NLS-1$ //$NON-NLS-2$
    }


    /**
     * Encodes the RFC 4517 Postal Address syntax.
     *
     * @param separator the separator used between address lines
     * @return a translator object for encoding
     */
    public static CharSequenceTranslator createPostalAddressEncoder( String separator )
    {
        return new LookupTranslator( Map.of(
            "\\", "\\5C", //$NON-NLS-1$ //$NON-NLS-2$
            "$", "\\24", //$NON-NLS-1$ //$NON-NLS-2$
            separator, "$" ) ); //$NON-NLS-1$
    }
}
