/*
 * LDAP Chai API
 * Copyright (c) 2006-2017 Novell, Inc.
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */

package com.novell.ldapchai.impl.edir;

import com.novell.ldapchai.ChaiUser;
import com.novell.ldapchai.cr.AbstractResponseSet;
import com.novell.ldapchai.cr.Answer;
import com.novell.ldapchai.cr.ChaiChallenge;
import com.novell.ldapchai.cr.ChaiChallengeSet;
import com.novell.ldapchai.cr.Challenge;
import com.novell.ldapchai.cr.ChallengeSet;
import com.novell.ldapchai.cr.bean.AnswerBean;
import com.novell.ldapchai.cr.bean.ChallengeBean;
import com.novell.ldapchai.exception.ChaiOperationException;
import com.novell.ldapchai.exception.ChaiUnavailableException;
import com.novell.ldapchai.exception.ChaiValidationException;
import com.novell.ldapchai.util.ChaiLogger;
import com.novell.ldapchai.util.StringHelper;
import com.novell.security.nmas.jndi.ldap.ext.GetLoginConfigRequest;
import com.novell.security.nmas.jndi.ldap.ext.PutLoginConfigRequest;
import com.novell.security.nmas.jndi.ldap.ext.PutLoginConfigResponse;
import com.novell.security.nmas.jndi.ldap.ext.PutLoginSecretRequest;
import com.novell.security.nmas.jndi.ldap.ext.PutLoginSecretResponse;
import com.novell.security.nmas.mgmt.NMASChallengeResponse;
import org.jdom2.Attribute;
import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.JDOMException;
import org.jdom2.Namespace;
import org.jdom2.filter.ElementFilter;
import org.jdom2.input.SAXBuilder;
import org.jdom2.output.Format;
import org.jdom2.output.XMLOutputter;

import javax.naming.ldap.ExtendedResponse;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.StringTokenizer;


public class NmasResponseSet extends AbstractResponseSet
{

    private static final ChaiLogger LOGGER = ChaiLogger.getLogger( NmasResponseSet.class.getName() );

    private ChaiUser user;

    static List<Challenge> parseNmasPolicyXML( final String str, final Locale locale )
            throws IOException, JDOMException
    {
        final List<Challenge> returnList = new ArrayList<Challenge>();

        final Reader xmlreader = new StringReader( str );
        final SAXBuilder builder = new SAXBuilder();
        final Document doc = builder.build( xmlreader );
        final boolean required = doc.getRootElement().getName().equals( "RequiredQuestions" );

        for ( final Iterator questionIterator = doc.getDescendants( new ElementFilter( "Question" ) ); questionIterator.hasNext(); )
        {
            final Element loopQ = ( Element ) questionIterator.next();
            final int maxLength = StringHelper.convertStrToInt( loopQ.getAttributeValue( "MaxLength" ), 255 );
            final int minLength = StringHelper.convertStrToInt( loopQ.getAttributeValue( "MinLength" ), 1 );

            final String challengeText = readDisplayString( loopQ, locale );

            final Challenge challenge = new ChaiChallenge( required, challengeText, minLength, maxLength, true, 0, false );
            returnList.add( challenge );
        }

        for ( Iterator iter = doc.getDescendants( new ElementFilter( "UserDefined" ) ); iter.hasNext(); )
        {
            final Element loopQ = ( Element ) iter.next();
            final int maxLength = StringHelper.convertStrToInt( loopQ.getAttributeValue( "MaxLength" ), 255 );
            final int minLength = StringHelper.convertStrToInt( loopQ.getAttributeValue( "MinLength" ), 1 );
            final Challenge challenge = new ChaiChallenge( required, null, minLength, maxLength, false, 0, false );
            returnList.add( challenge );
        }

        return returnList;
    }

    private static String readDisplayString( final Element questionElement, final Locale locale )
    {

        final Namespace xmlNamespace = Namespace.getNamespace( "xml", "http://www.w3.org/XML/1998/namespace" );

        // someday ResoureBundle won't suck and this will be a 5 line method.

        // see if the node has any localized displays.
        final List displayChildren = questionElement.getChildren( "display" );

        // if no locale specified, or if no localized text is available, just use the default.
        if ( locale == null || displayChildren == null || displayChildren.size() < 1 )
        {
            return questionElement.getText();
        }

        // convert the xml 'display' elements to a map of locales/strings
        final Map<Locale, String> localizedStringMap = new HashMap<Locale, String>();
        for ( final Object loopDisplayChild : displayChildren )
        {
            final Element loopDisplay = ( Element ) loopDisplayChild;
            final Attribute localeAttr = loopDisplay.getAttribute( "lang", xmlNamespace );
            if ( localeAttr != null )
            {
                final String localeStr = localeAttr.getValue();
                final String displayStr = loopDisplay.getText();
                final Locale localeKey = parseLocaleString( localeStr );
                localizedStringMap.put( localeKey, displayStr );
            }
        }

        final Locale matchedLocale = localeResolver( locale, localizedStringMap.keySet() );

        if ( matchedLocale != null )
        {
            return localizedStringMap.get( matchedLocale );
        }

        // none found, so just return the default string.
        return questionElement.getText();
    }

    static NmasResponseSet readNmasUserResponseSet(
            final ChaiUser theUser
    )
            throws ChaiUnavailableException, ChaiValidationException
    {
        final GetLoginConfigRequest request = new GetLoginConfigRequest();
        request.setObjectDN( theUser.getEntryDN() );
        request.setTag( "ChallengeResponseQuestions" );
        request.setMethodID( NMASChallengeResponse.METHOD_ID );
        request.setMethodIDLen( NMASChallengeResponse.METHOD_ID.length * 4 );
        try
        {
            final ExtendedResponse response = theUser.getChaiProvider().extendedOperation( request );
            final byte[] responseValue = response.getEncodedValue();

            if ( responseValue == null )
            {
                return null;
            }

            final String xmlString = new String( responseValue, "UTF8" );
            LOGGER.trace( "[parse v3]: read ChallengeResponseQuestions from server: " + xmlString );

            ChallengeSet cs = null;
            int parseAttempts = 0;
            final StringBuilder parsingErrorMsg = new StringBuilder();

            {
                final int beginIndex = xmlString.indexOf( "<" );
                if ( beginIndex > 0 )
                {
                    try
                    {
                        parseAttempts++;
                        final String xmlSubstring = xmlString.substring( beginIndex, xmlString.length() );
                        LOGGER.trace( "attempting parse of index stripped value: " + xmlSubstring );
                        cs = parseNmasUserResponseXML( xmlSubstring );
                        LOGGER.trace( "successfully parsed nmas ChallengeResponseQuestions response after index " + beginIndex );
                    }
                    catch ( JDOMException e )
                    {
                        if ( parsingErrorMsg.length() > 0 )
                        {
                            parsingErrorMsg.append( ", " );
                        }
                        parsingErrorMsg.append( "error parsing index stripped value: " ).append( e.getMessage() );
                        LOGGER.trace( "unable to parse index stripped ChallengeResponseQuestions nmas response; error: " + e.getMessage() );
                    }
                }
            }

            if ( cs == null )
            {
                if ( xmlString.startsWith( "<?xml" ) )
                {
                    try
                    {
                        parseAttempts++;
                        cs = parseNmasUserResponseXML( xmlString );
                    }
                    catch ( JDOMException e )
                    {
                        parsingErrorMsg.append( "error parsing raw value: " ).append( e.getMessage() );
                        LOGGER.trace( "unable to parse raw ChallengeResponseQuestions nmas response; will retry after stripping header; error: " + e.getMessage() );
                    }
                    LOGGER.trace( "successfully parsed full nmas ChallengeResponseQuestions response" );
                }
            }

            if ( cs == null )
            {
                if ( xmlString.length() > 16 )
                {
                    // first 16 bytes are non-xml header.
                    final String strippedXml = xmlString.substring( 16 );
                    try
                    {
                        parseAttempts++;
                        cs = parseNmasUserResponseXML( strippedXml );
                        LOGGER.trace( "successfully parsed full nmas ChallengeResponseQuestions response" );
                    }
                    catch ( JDOMException e )
                    {
                        if ( parsingErrorMsg.length() > 0 )
                        {
                            parsingErrorMsg.append( ", " );
                        }
                        parsingErrorMsg.append( "error parsing header stripped value: " ).append( e.getMessage() );
                        LOGGER.trace( "unable to parse stripped ChallengeResponseQuestions nmas response; error: " + e.getMessage() );
                    }
                }
            }


            if ( cs == null )
            {
                final String logMsg = "unable to parse nmas ChallengeResponseQuestions: " + parsingErrorMsg;
                if ( parseAttempts > 0 && xmlString.length() > 16 )
                {
                    LOGGER.error( logMsg );
                }
                else
                {
                    LOGGER.trace( logMsg );

                }
                return null;
            }

            final Map<Challenge, String> crMap = new HashMap<Challenge, String>();
            for ( final Challenge loopChallenge : cs.getChallenges() )
            {
                crMap.put( loopChallenge, null );
            }

            return new NmasResponseSet( crMap, cs.getLocale(), cs.getMinRandomRequired(), AbstractResponseSet.STATE.READ, theUser, cs.getIdentifier() );
        }
        catch ( ChaiOperationException e )
        {
            LOGGER.error( "error reading nmas user response for " + theUser.getEntryDN() + ", error: " + e.getMessage() );
        }
        catch ( IOException e )
        {
            LOGGER.error( "error reading nmas user response for " + theUser.getEntryDN() + ", error: " + e.getMessage() );
        }
        return null;
    }

    static ChallengeSet parseNmasUserResponseXML( final String str )
            throws IOException, JDOMException, ChaiValidationException
    {
        final List<Challenge> returnList = new ArrayList<Challenge>();

        final Reader xmlreader = new StringReader( str );
        final SAXBuilder builder = new SAXBuilder();
        final Document doc = builder.build( xmlreader );

        final Element rootElement = doc.getRootElement();
        final int minRandom = StringHelper.convertStrToInt( rootElement.getAttributeValue( "RandomQuestions" ), 0 );

        final String guidValue;
        {
            final Attribute guidAttribute = rootElement.getAttribute( "GUID" );
            guidValue = guidAttribute == null ? null : guidAttribute.getValue();
        }

        for ( Iterator iter = doc.getDescendants( new ElementFilter( "Challenge" ) ); iter.hasNext(); )
        {
            final Element loopQ = ( Element ) iter.next();
            final int maxLength = StringHelper.convertStrToInt( loopQ.getAttributeValue( "MaxLength" ), 255 );
            final int minLength = StringHelper.convertStrToInt( loopQ.getAttributeValue( "MinLength" ), 2 );
            final String defineStrValue = loopQ.getAttributeValue( "Define" );
            final boolean adminDefined = "Admin".equalsIgnoreCase( defineStrValue );
            final String typeStrValue = loopQ.getAttributeValue( "Type" );
            final boolean required = "Required".equalsIgnoreCase( typeStrValue );
            final String challengeText = loopQ.getText();

            final Challenge challenge = new ChaiChallenge( required, challengeText, minLength, maxLength, adminDefined, 0, false );
            returnList.add( challenge );
        }

        return new ChaiChallengeSet( returnList, minRandom, null, guidValue );
    }

    NmasResponseSet(
            final Map<Challenge, String> crMap,
            final Locale locale,
            final int minimumRandomRequired,
            final STATE state,
            final ChaiUser user,
            final String csIdentifier
    )
            throws ChaiValidationException
    {
        super( convertAnswerTextMap( crMap ), Collections.emptyMap(), locale, minimumRandomRequired, state, csIdentifier );
        this.user = user;
    }

    public String stringValue()
            throws UnsupportedOperationException
    {
        throw new UnsupportedOperationException( "stringValue() is not supported by NMAS response sets" );
    }

    public boolean test( final Map<Challenge, String> responseTest )
    {
        //@todo TODO
        throw new UnsupportedOperationException( "NMAS Response testing not yet implemented" );
    }

    boolean write()
            throws ChaiUnavailableException, ChaiOperationException
    {
        if ( this.state != STATE.NEW )
        {
            throw new IllegalStateException( "RepsonseSet not suitable for writing (not in NEW state)" );
        }

        //write challenge set questions to Nmas Login Config
        try
        {
            final PutLoginConfigRequest request = new PutLoginConfigRequest();
            request.setObjectDN( user.getEntryDN() );
            final byte[] data = csToNmasXML( getChallengeSet(), this.csIdentifier ).getBytes( "UTF8" );
            request.setData( data );
            request.setDataLen( data.length );
            request.setTag( "ChallengeResponseQuestions" );
            request.setMethodID( NMASChallengeResponse.METHOD_ID );
            request.setMethodIDLen( NMASChallengeResponse.METHOD_ID.length * 4 );

            final ExtendedResponse response = user.getChaiProvider().extendedOperation( request );
            if ( response != null && ( ( PutLoginConfigResponse ) response ).getNmasRetCode() != 0 )
            {
                LOGGER.debug( "nmas error writing question: " + ( ( PutLoginConfigResponse ) response ).getNmasRetCode() );
                return false;
            }
        }
        catch ( UnsupportedEncodingException e )
        {
            LOGGER.error( "error while writing nmas questions: " + e.getMessage() );
            return false;
        }
        catch ( ChaiOperationException e )
        {
            LOGGER.error( "error while writing nmas questions: " + e.getMessage() );
            throw e;
        }
        catch ( ChaiValidationException e )
        {
            LOGGER.error( "error while writing nmas questions: " + e.getMessage() );
            throw ChaiOperationException.forErrorMessage( e.getMessage() );
        }

        boolean success = true;

        //write responses
        for ( final Map.Entry<Challenge, Answer> entry : crMap.entrySet() )
        {
            final Challenge loopChallenge = entry.getKey();
            try
            {
                final byte[] data = ( ( NmasAnswer ) entry.getValue() ).getAnswerText().getBytes( "UTF8" );
                final PutLoginSecretRequest request = new PutLoginSecretRequest();
                request.setObjectDN( user.getEntryDN() );
                request.setData( data );
                request.setDataLen( data.length );
                request.setTag( loopChallenge.getChallengeText() );
                request.setMethodID( NMASChallengeResponse.METHOD_ID );
                request.setMethodIDLen( NMASChallengeResponse.METHOD_ID.length * 4 );

                final ExtendedResponse response = user.getChaiProvider().extendedOperation( request );
                if ( response != null && ( ( PutLoginSecretResponse ) response ).getNmasRetCode() != 0 )
                {
                    LOGGER.debug( "nmas error writing answer: " + ( ( PutLoginSecretResponse ) response ).getNmasRetCode() );
                    success = false;
                }
            }
            catch ( Exception e )
            {
                LOGGER.error( "error while writing nmas answer: " + e.getMessage() );
            }
        }

        if ( success )
        {
            LOGGER.info( "successfully wrote NMAS challenge/response set for user " + user.getEntryDN() );
            this.state = STATE.WRITTEN;
        }

        return success;
    }

    public static Locale localeResolver( final Locale desiredLocale, final Collection<Locale> localePool )
    {
        if ( desiredLocale == null || localePool == null || localePool.isEmpty() )
        {
            return null;
        }

        for ( final Locale loopLocale : localePool )
        {
            if ( loopLocale.getLanguage().equalsIgnoreCase( desiredLocale.getLanguage() ) )
            {
                if ( loopLocale.getCountry().equalsIgnoreCase( desiredLocale.getCountry() ) )
                {
                    if ( loopLocale.getVariant().equalsIgnoreCase( desiredLocale.getVariant() ) )
                    {
                        return loopLocale;
                    }
                }
            }
        }

        for ( final Locale loopLocale : localePool )
        {
            if ( loopLocale.getLanguage().equalsIgnoreCase( desiredLocale.getLanguage() ) )
            {
                if ( loopLocale.getCountry().equalsIgnoreCase( desiredLocale.getCountry() ) )
                {
                    return loopLocale;
                }
            }
        }

        for ( final Locale loopLocale : localePool )
        {
            if ( loopLocale.getLanguage().equalsIgnoreCase( desiredLocale.getLanguage() ) )
            {
                return loopLocale;
            }
        }

        final Locale defaultLocale = parseLocaleString( "" );
        if ( localePool.contains( defaultLocale ) )
        {
            return defaultLocale;
        }

        return null;
    }

    public static Locale parseLocaleString( final String localeString )
    {
        if ( localeString == null )
        {
            return new Locale( "" );
        }

        final StringTokenizer st = new StringTokenizer( localeString, "_" );

        if ( !st.hasMoreTokens() )
        {
            return new Locale( "" );
        }

        final String language = st.nextToken();
        if ( !st.hasMoreTokens() )
        {
            return new Locale( language );
        }

        final String country = st.nextToken();
        if ( !st.hasMoreTokens() )
        {
            return new Locale( language, country );
        }

        final String variant = st.nextToken( "" );
        return new Locale( language, country, variant );
    }

    private static final String NMAS_XML_ROOTNODE = "Challenges";
    private static final String NMAS_XML_ATTR_RANDOM_COUNT = "RandomQuestions";
    private static final String NMAS_XML_NODE_CHALLENGE = "Challenge";
    private static final String NMAS_XML_ATTR_TYPE = "Type";
    private static final String NMAS_XML_ATTR_DEFINE = "Define";
    private static final String NMAS_XML_ATTR_MIN_LENGTH = "MinLength";
    private static final String NMAS_XML_ATTR_MAX_LENGTH = "MaxLength";

    static String csToNmasXML( final ChallengeSet cs, final String guidValue )
    {
        final Element rootElement = new Element( NMAS_XML_ROOTNODE );
        rootElement.setAttribute( NMAS_XML_ATTR_RANDOM_COUNT, String.valueOf( cs.getMinRandomRequired() ) );
        if ( guidValue != null )
        {
            rootElement.setAttribute( "GUID", guidValue );
        }
        else
        {
            rootElement.setAttribute( "GUID", "0" );
        }

        for ( final Challenge challenge : cs.getChallenges() )
        {
            final Element loopElement = new Element( NMAS_XML_NODE_CHALLENGE );
            if ( challenge.getChallengeText() != null )
            {
                loopElement.setText( challenge.getChallengeText() );
            }

            if ( challenge.isAdminDefined() )
            {
                loopElement.setAttribute( NMAS_XML_ATTR_DEFINE, "Admin" );
            }
            else
            {
                loopElement.setAttribute( NMAS_XML_ATTR_DEFINE, "User" );
            }

            if ( challenge.isRequired() )
            {
                loopElement.setAttribute( NMAS_XML_ATTR_TYPE, "Required" );
            }
            else
            {
                loopElement.setAttribute( NMAS_XML_ATTR_TYPE, "Random" );
            }

            loopElement.setAttribute( NMAS_XML_ATTR_MIN_LENGTH, String.valueOf( challenge.getMinLength() ) );
            loopElement.setAttribute( NMAS_XML_ATTR_MAX_LENGTH, String.valueOf( challenge.getMaxLength() ) );

            rootElement.addContent( loopElement );
        }

        final XMLOutputter outputter = new XMLOutputter();
        final Format format = Format.getRawFormat();
        format.setTextMode( Format.TextMode.PRESERVE );
        format.setLineSeparator( "" );
        outputter.setFormat( format );
        return outputter.outputString( rootElement );
    }

    private static Map<Challenge, Answer> convertAnswerTextMap( final Map<Challenge, String> crMap )
    {
        final Map<Challenge, Answer> returnMap = new LinkedHashMap<>();
        for ( final Map.Entry<Challenge, String> entry : crMap.entrySet() )
        {
            final Challenge challenge = entry.getKey();
            final String answerText = entry.getValue();
            returnMap.put( challenge, new NmasAnswer( answerText ) );
        }
        return returnMap;
    }

    private static class NmasAnswer implements Answer
    {
        private String answerText;

        private NmasAnswer( final String answerText )
        {
            this.answerText = answerText;
        }

        public String getAnswerText()
        {
            return answerText;
        }

        public boolean testAnswer( final String answer )
        {
            //@todo TODO
            throw new UnsupportedOperationException( "NMAS Response testing not yet implemented" );
        }

        public Element toXml()
        {
            return null;
        }

        public AnswerBean asAnswerBean()
        {
            throw new UnsupportedOperationException( "NMAS stored responses do not support retrieval of answers" );
        }
    }

    public List<ChallengeBean> asChallengeBeans( final boolean includeAnswers )
    {
        if ( includeAnswers )
        {
            throw new UnsupportedOperationException( "NMAS stored responses do not support retrieval of answers" );
        }

        if ( crMap == null )
        {
            return Collections.emptyList();
        }

        final List<ChallengeBean> returnList = new ArrayList<ChallengeBean>();
        for ( final Challenge challenge : this.crMap.keySet() )
        {
            returnList.add( challenge.asChallengeBean() );
        }
        return returnList;
    }

    public List<ChallengeBean> asHelpdeskChallengeBeans( final boolean includeAnswers )
    {
        //@todo TODO
        throw new UnsupportedOperationException( "NMAS stored responses do not support Helpdesk Challenges" );
    }
}