/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 *
 * Copyright (c) 2010-2011 Oracle and/or its affiliates. All rights reserved.
 *
 * The contents of this file are subject to the terms of either the GNU
 * General Public License Version 2 only ("GPL") or the Common Development
 * and Distribution License("CDDL") (collectively, the "License").  You
 * may not use this file except in compliance with the License.  You can
 * obtain a copy of the License at
 * http://glassfish.java.net/public/CDDL+GPL_1_1.html
 * or packager/legal/LICENSE.txt.  See the License for the specific
 * language governing permissions and limitations under the License.
 *
 * When distributing the software, include this License Header Notice in each
 * file and include the License file at packager/legal/LICENSE.txt.
 *
 * GPL Classpath Exception:
 * Oracle designates this particular file as subject to the "Classpath"
 * exception as provided by Oracle in the GPL Version 2 section of the License
 * file that accompanied this code.
 *
 * Modifications:
 * If applicable, add the following below the License Header, with the fields
 * enclosed by brackets [] replaced by your own identifying information:
 * "Portions Copyright [year] [name of copyright owner]"
 *
 * Contributor(s):
 * If you wish your version of this file to be governed by only the CDDL or
 * only the GPL Version 2, indicate your decision by adding "[Contributor]
 * elects to include this software in this distribution under the [CDDL or GPL
 * Version 2] license."  If you don't indicate a single choice of license, a
 * recipient has the option to distribute your version of this file under
 * either the CDDL, the GPL Version 2 or to extend the choice of license to
 * its licensees as provided above.  However, if you add GPL Version 2 code
 * and therefore, elected the GPL Version 2 license, then the option applies
 * only if the new code is made subject to such option by the copyright
 * holder.
 */
package com.sun.jersey.wadl.resourcedoc;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.io.StringWriter;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.bind.JAXBContext;
import javax.xml.bind.Marshaller;
import javax.xml.namespace.QName;

import org.apache.xml.serialize.OutputFormat;
import org.apache.xml.serialize.XMLSerializer;

import com.sun.javadoc.AnnotationDesc;
import com.sun.javadoc.ClassDoc;
import com.sun.javadoc.DocErrorReporter;
import com.sun.javadoc.MemberDoc;
import com.sun.javadoc.MethodDoc;
import com.sun.javadoc.ParamTag;
import com.sun.javadoc.Parameter;
import com.sun.javadoc.RootDoc;
import com.sun.javadoc.SeeTag;
import com.sun.javadoc.Tag;
import com.sun.javadoc.AnnotationDesc.ElementValuePair;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.AnnotationDocType;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.ClassDocType;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.MethodDocType;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.NamedValueType;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.ParamDocType;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.RepresentationDocType;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.RequestDocType;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.ResourceDocType;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.ResponseDocType;
import com.sun.jersey.server.wadl.generators.resourcedoc.model.WadlParamType;

/**
 * This doclet creates a resourcedoc xml file. The ResourceDoc file contains the javadoc documentation
 * of resource classes, so that this can be used for extending generated wadl with useful
 * documentation.<br>
 * Created on: Jun 7, 2008<br>
 * 
 * @author <a href="mailto:martin.grotzke@freiheit.com">Martin Grotzke</a>
 * @version $Id$
 */
public class ResourceDoclet {

    private static final Pattern PATTERN_RESPONSE_REPRESENATION = Pattern.compile( "@response\\.representation\\.([\\d]+)\\..*" );
    private static final String OPTION_OUTPUT = "-output";
    private static final String OPTION_CLASSPATH = "-classpath";
    private static final String OPTION_DOC_PROCESSORS = "-processors";

    private static final Logger LOG = Logger.getLogger( ResourceDoclet.class
            .getName() );

    /**
     * Start the doclet.
     * 
     * @param root
     * @return true if no exception is thrown
     */
    public static boolean start( RootDoc root ) {
        final String output = getOptionArg( root.options(), OPTION_OUTPUT );
        
        final String classpath = getOptionArg( root.options(), OPTION_CLASSPATH );
        // LOG.info( "Have classpath: " + classpath );
        final String[] classpathElements = classpath.split( ":" );
        
        final ClassLoader cl = Thread.currentThread().getContextClassLoader();
        final ClassLoader ncl = new Loader( classpathElements,
                ResourceDoclet.class.getClassLoader() );
        Thread.currentThread().setContextClassLoader( ncl );

        
        final String docProcessorOption = getOptionArg( root.options(), OPTION_DOC_PROCESSORS );
        final String[] docProcessors = docProcessorOption != null ? docProcessorOption.split( ":" ) : null;
        final DocProcessorWrapper docProcessor = new DocProcessorWrapper();
        try {
            if ( docProcessors != null && docProcessors.length > 0 ) {
                final Class<?> clazz = Class.forName( docProcessors[0], true, Thread.currentThread().getContextClassLoader() );
                final Class<? extends DocProcessor> dpClazz = clazz.asSubclass( DocProcessor.class );
                docProcessor.add( dpClazz.newInstance() );
            }
        } catch ( Exception e ) {
            LOG.log( Level.SEVERE, "Could not load docProcessors " + docProcessorOption, e );
        }

        try {
            final ResourceDocType result = new ResourceDocType();
    
            final ClassDoc[] classes = root.classes();
            for ( ClassDoc classDoc : classes ) {
                LOG.fine( "Writing class " + classDoc.qualifiedTypeName() );
                final ClassDocType classDocType = new ClassDocType();
                classDocType.setClassName( classDoc.qualifiedTypeName() );
                classDocType.setCommentText( classDoc.commentText() );
                docProcessor.processClassDoc( classDoc, classDocType );
                
                for ( MethodDoc methodDoc : classDoc.methods() ) {
                    
                    final MethodDocType methodDocType = new MethodDocType();
                    methodDocType.setMethodName( methodDoc.name() );
                    methodDocType.setCommentText( methodDoc.commentText() );
                    docProcessor.processMethodDoc( methodDoc, methodDocType );
                    
                    addParamDocs( methodDoc, methodDocType, docProcessor );
                    
                    addRequestRepresentationDoc( methodDoc, methodDocType );
                    
                    addResponseDoc( methodDoc, methodDocType );
                    
                    classDocType.getMethodDocs().add( methodDocType );
                }
                
                result.getDocs().add( classDocType );
            }
    
            try {
                final Class<?>[] clazzes = getJAXBContextClasses( result, docProcessor );
                final JAXBContext c = JAXBContext.newInstance( clazzes );
                final Marshaller m = c.createMarshaller();
                m.setProperty( Marshaller.JAXB_FORMATTED_OUTPUT, true );
                final OutputStream out = new BufferedOutputStream( new FileOutputStream( output ) );
                
                
                final String[] cdataElements = getCDataElements( docProcessor );
                final XMLSerializer serializer = getXMLSerializer( out, cdataElements );
                
                m.marshal( result, serializer );
                out.close();
                
                LOG.info( "Wrote " + output );
                
            } catch (Exception e) {
                LOG.log( Level.SEVERE, "Could not serialize ResourceDoc.", e );
                return false;
            }
        } finally {
            Thread.currentThread().setContextClassLoader( cl );
        }

        return true;
    }

    private static String[] getCDataElements( DocProcessor docProcessor ) {
        final String[] original = new String[] { "ns1^commentText", "ns2^commentText", "^commentText" };
        if ( docProcessor == null ) {
            return original;
        }
        else {
            final String[] cdataElements = docProcessor.getCDataElements();
            if ( cdataElements == null || cdataElements.length == 0 ) {
                return original;
            }
            else {
                
                final String[] result = copyOf( original, original.length + cdataElements.length );
                for ( int i = 0; i < cdataElements.length; i++ ) {
                    result[ original.length + i ] = cdataElements[i];
                }
                return result;
            }
        }
    }

    @SuppressWarnings("unchecked")
    private static <T,U> T[] copyOf( U[] original, int newLength ) {
        final T[] copy = ((Object)original.getClass() == (Object)Object[].class)
            ? (T[]) new Object[newLength]
            : (T[]) Array.newInstance(original.getClass().getComponentType(), newLength);
        System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));
        return copy;
    }

    private static Class<?>[] getJAXBContextClasses(
            final ResourceDocType result, DocProcessor docProcessor ) {
        final Class<?>[] clazzes;
        if ( docProcessor == null ) {
            clazzes = new Class<?>[1];
        }
        else {
            final Class<?>[] requiredJaxbContextClasses = docProcessor.getRequiredJaxbContextClasses();
            if ( requiredJaxbContextClasses != null ) {
                clazzes = new Class<?>[1 + requiredJaxbContextClasses.length ];
                for ( int i = 0; i < requiredJaxbContextClasses.length; i++ ) {
                    clazzes[i + 1] = requiredJaxbContextClasses[i];
                }
            }
            else {
                clazzes = new Class<?>[1];
            }
        }
        clazzes[0] = result.getClass();
        return clazzes;
    }
    
    private static XMLSerializer getXMLSerializer( OutputStream os, String[] cdataElements ) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
        // configure an OutputFormat to handle CDATA
        OutputFormat of = new OutputFormat();

        // specify which of your elements you want to be handled as CDATA.
        // The use of the '^' between the namespaceURI and the localname
        // seems to be an implementation detail of the xerces code.
        // When processing xml that doesn't use namespaces, simply omit the
        // namespace prefix as shown in the third CDataElement below.
        of.setCDataElements( cdataElements );

        // set any other options you'd like
        of.setPreserveSpace(true);
        of.setIndenting(true);

        // create the serializer
        XMLSerializer serializer = new XMLSerializer(of);
        
        serializer.setOutputByteStream( os );

        return serializer;
    }

    private static void addResponseDoc( MethodDoc methodDoc,
            final MethodDocType methodDocType ) {
        
        final ResponseDocType responseDoc = new ResponseDocType();
        
        final Tag returnTag = getSingleTagOrNull( methodDoc, "return" );
        if ( returnTag != null ) {
            responseDoc.setReturnDoc( returnTag.text() );
        }
        
        final Tag[] responseParamTags = methodDoc.tags( "response.param" );
        for ( Tag responseParamTag : responseParamTags ) {
            // LOG.info( "Have responseparam tag: " + print( responseParamTag ) );
            final WadlParamType wadlParam = new WadlParamType();
            for ( Tag inlineTag : responseParamTag.inlineTags() ) {
                final String tagName = inlineTag.name();
                final String tagText = inlineTag.text();
                /* skip empty tags
                 */
                if ( isEmpty( tagText ) ) {
                    if ( LOG.isLoggable( Level.FINE ) ) {
                        LOG.fine( "Skipping empty inline tag of @response.param in method " +
                            methodDoc.qualifiedName() + ": " + tagName  );
                    }
                    continue;
                }
                if ( "@name".equals( tagName ) ) {
                    wadlParam.setName( tagText );
                }
                else if ( "@style".equals( tagName ) ) {
                    wadlParam.setStyle( tagText );
                }
                else if ( "@type".equals( tagName ) ) {
                    wadlParam.setType( QName.valueOf( tagText ) );
                }
                else if ( "@doc".equals( tagName ) ) {
                    wadlParam.setDoc( tagText );
                }
                else {
                    LOG.warning( "Unknown inline tag of @response.param in method " +
                            methodDoc.qualifiedName() + ": " + tagName +
                            " (value: " + tagText + ")" );
                }
            }
            responseDoc.getWadlParams().add( wadlParam );
        }

        final Map<String, List<Tag>> tagsByStatus = getResponseRepresentationTags( methodDoc );
        for ( Entry<String, List<Tag>> entry : tagsByStatus.entrySet() ) {
            final RepresentationDocType representationDoc = new RepresentationDocType();
            representationDoc.setStatus( Long.valueOf( entry.getKey() ) );
            for ( Tag tag : entry.getValue() ) {
                if ( tag.name().endsWith( ".qname" ) ) {
                    representationDoc.setElement( QName.valueOf( tag.text() ) );
                }
                else if ( tag.name().endsWith( ".mediaType" ) ) {
                    representationDoc.setMediaType( tag.text() );
                }
                else if ( tag.name().endsWith( ".example" ) ) {
                    representationDoc.setExample( getSerializedExample( tag ) );
                }
                else if ( tag.name().endsWith( ".doc" ) ) {
                    representationDoc.setDoc( tag.text() );
                }
                else {
                    LOG.warning( "Unknown response representation tag " + tag.name() );
                }
            }
            responseDoc.getRepresentations().add( representationDoc );
        }
        
        methodDocType.setResponseDoc( responseDoc );
    }

    private static boolean isEmpty( String value ) {
        return value == null || value.length() == 0 || value.trim().length() == 0 ? true : false;
    }

    private static void addRequestRepresentationDoc( MethodDoc methodDoc,
            final MethodDocType methodDocType ) {
        final Tag requestElement = getSingleTagOrNull( methodDoc, "request.representation.qname" );
        final Tag requestExample = getSingleTagOrNull( methodDoc, "request.representation.example" );
        if ( requestElement != null || requestExample != null ) {
            final RequestDocType requestDoc = new RequestDocType();
            final RepresentationDocType representationDoc = new RepresentationDocType();
            
            /* requestElement exists
             */
            if ( requestElement != null ) {
                representationDoc.setElement( QName.valueOf( requestElement.text() ) );
            }
            
            /* requestExample exists
             */
            if ( requestExample != null ) {
                final String example = getSerializedExample( requestExample );
                if ( !isEmpty( example ) ) {
                    representationDoc.setExample( example );
                }
                else {
                    LOG.warning( "Could not get serialized example for method " + methodDoc.qualifiedName() );
                }
            }
            
            requestDoc.setRepresentationDoc( representationDoc );
            methodDocType.setRequestDoc( requestDoc );
        }
    }

    private static Map<String, List<Tag>> getResponseRepresentationTags(
            MethodDoc methodDoc ) {
        final Map<String,List<Tag>> tagsByStatus = new HashMap<String, List<Tag>>();
        for ( Tag tag : methodDoc.tags() ) {
            final Matcher matcher = PATTERN_RESPONSE_REPRESENATION.matcher( tag.name() );
            if ( matcher.matches() ) {
                final String status = matcher.group( 1 );
                List<Tag> tags = tagsByStatus.get( status );
                if ( tags == null ) {
                    tags = new ArrayList<Tag>();
                    tagsByStatus.put( status, tags );
                }
                tags.add( tag );
            }
        }
        return tagsByStatus;
    }

    /**
     * Searches an <code>@link</code> tag within the inline tags of the specified tags
     * and serializes the referenced instance.
     * @param tag
     * @return
     * @author Martin Grotzke
     */
    private static String getSerializedExample( Tag tag ) {
        if ( tag != null ) {
            final Tag[] inlineTags = tag.inlineTags();
            if ( inlineTags != null && inlineTags.length > 0 ) {
                for ( Tag inlineTag : inlineTags ) {
                    if ( LOG.isLoggable( Level.FINE ) ) {
                        LOG.fine( "Have inline tag: " + print( inlineTag ) );
                    }
                    if ( "@link".equals( inlineTag.name() ) ) {
                        if ( LOG.isLoggable( Level.FINE ) ) {
                            LOG.fine( "Have link: " + print( inlineTag ) );
                        }
                        final SeeTag linkTag = (SeeTag) inlineTag;
                        return getSerializedLinkFromTag( linkTag );
                    }
                    else if ( !isEmpty( inlineTag.text() ) ) {
                        return inlineTag.text();
                    }
                }
            }
            else {
                LOG.fine( "Have example: " + print( tag ) );
                return tag.text();
            }
        }
        return null;
    }

    private static Tag getSingleTagOrNull( MethodDoc methodDoc, String tagName ) {
        final Tag[] tags = methodDoc.tags( tagName );
        if ( tags != null && tags.length == 1 ) {
            return tags[0];
        }
        return null;
    }

    private static void addParamDocs( MethodDoc methodDoc,
            final MethodDocType methodDocType,
            final DocProcessor docProcessor ) {
        final Parameter[] parameters = methodDoc.parameters();
        final ParamTag[] paramTags = methodDoc.paramTags();
        
        /* only use both javadoc and reflection information when the number
         * of params are the same
         */
        if ( parameters != null && paramTags != null
                && parameters.length == paramTags.length ) {
            
            for ( int i = 0; i < parameters.length; i++ ) {
                final Parameter parameter = parameters[i];
                
                /* TODO: this only works if the params and tags are in the same
                 * order. If the param tags are mixed up, the comments for parameters
                 * will be wrong.
                 */
                final ParamTag paramTag = paramTags[i];
                
                final ParamDocType paramDocType = new ParamDocType();
                paramDocType.setParamName( paramTag.parameterName() );
                paramDocType.setCommentText( paramTag.parameterComment() );
                docProcessor.processParamTag( paramTag, parameter, paramDocType );
                
                AnnotationDesc[] annotations = parameter.annotations();
                if ( annotations != null  ) {
                    for ( AnnotationDesc annotationDesc : annotations ) {
                        final AnnotationDocType annotationDocType = new AnnotationDocType();
                        final String typeName = annotationDesc.annotationType().qualifiedName();
                        annotationDocType.setAnnotationTypeName( typeName );
                        for ( ElementValuePair elementValuePair : annotationDesc.elementValues() ) {
                            final NamedValueType namedValueType = new NamedValueType();
                            namedValueType.setName( elementValuePair.element().name() );
                            namedValueType.setValue( elementValuePair.value().value().toString() );
                            annotationDocType.getAttributeDocs().add( namedValueType );
                        }
                        paramDocType.getAnnotationDocs().add( annotationDocType );
                    }
                }
                
                methodDocType.getParamDocs().add( paramDocType );
                
            }
            
        }
    }

    private static String getSerializedLinkFromTag( final SeeTag linkTag ) {
        final MemberDoc referencedMember = linkTag.referencedMember();
        
        if ( referencedMember == null ) {
            throw new NullPointerException( "Referenced member of @link "+ print( linkTag ) +" cannot be resolved." );
        }
        
        if ( !referencedMember.isStatic() ) {
            LOG.warning( "Referenced member of @link "+ print( linkTag ) +" is not static." +
                    " Right now only references to static members are supported." );
            return null;
        }
        
        /* Get referenced example bean
         */
        final ClassDoc containingClass = referencedMember.containingClass();
        final Object object; 
        try {
            Field declaredField = Class.forName( containingClass.qualifiedName(), false, Thread.currentThread().getContextClassLoader() ).getDeclaredField( referencedMember.name() );
            if ( referencedMember.isFinal() ) {
                declaredField.setAccessible( true );
            }
            object = declaredField.get( null );
            LOG.log( Level.FINE, "Got object " + object );
        } catch ( Exception e ) {
            LOG.info( "Have classloader: " + ResourceDoclet.class.getClassLoader().getClass() );
            LOG.info( "Have thread classloader " + Thread.currentThread().getContextClassLoader().getClass() );
            LOG.info( "Have system classloader " + ClassLoader.getSystemClassLoader().getClass() );
            LOG.log( Level.SEVERE, "Could not get field " + referencedMember.qualifiedName(), e );
            return null;
        }
        
        /* marshal the bean to xml
         */
        try {
            final JAXBContext jaxbContext = JAXBContext.newInstance( object.getClass() );
            final StringWriter stringWriter = new StringWriter();
            final Marshaller marshaller = jaxbContext.createMarshaller();
            marshaller.setProperty( Marshaller.JAXB_FORMATTED_OUTPUT, true );
            marshaller.marshal( object, stringWriter );
            final String result = stringWriter.getBuffer().toString();
            LOG.log( Level.FINE, "Got marshalled output:\n" + result );
            return result;
        } catch ( Exception e ) {
            LOG.log( Level.SEVERE, "Could serialize bean to xml: " + object, e );
            return null;
        }
    }

    private static String print( Tag tag ) {
        final StringBuilder sb = new StringBuilder();
        sb.append( tag.getClass() ).append( "[" );
        sb.append( "firstSentenceTags=" ).append( toCSV( tag.firstSentenceTags() ) );
        sb.append( ", inlineTags=" ).append( toCSV( tag.inlineTags() ) );
        sb.append( ", kind=" ).append( tag.kind() );
        sb.append( ", name=" ).append( tag.name() );
        sb.append( ", text=" ).append( tag.text() );
        sb.append( "]" );
        return sb.toString();
    }

    static <T> String toCSV( Tag[] items ) {
        if ( items == null ) {
            return null;
        }
        return toCSV( Arrays.asList( items ) );
    }
    
    static <I> String toCSV( Collection<Tag> items ) {
        return toCSV( items, ", ", null );
    }
    
    static <I> String toCSV( Collection<Tag> items, String separator, String delimiter ) {
        if ( items == null ) {
            return null;
        }
        if ( items.isEmpty() ) {
            return "";
        }
        final StringBuilder sb = new StringBuilder();
        for ( final Iterator<Tag> iter = items.iterator(); iter.hasNext(); ) {
            if ( delimiter != null ) {
                sb.append( delimiter );
            }
            final Tag item = iter.next();
            sb.append( item.name() );
            if ( delimiter != null ) {
                sb.append( delimiter );
            }
            if ( iter.hasNext() ) {
                sb.append( separator );
            }
        }
        return sb.toString();
    }

    /**
     * Return array length for given option: 1 + the number of arguments that
     * the option takes.
     * 
     * @param option
     * @return the number of args for the specified option
     */
    public static int optionLength( String option ) {
        LOG.fine( "Invoked with option " + option );
        
        if ( OPTION_OUTPUT.equals( option )
                || OPTION_CLASSPATH.equals( option )
                || OPTION_DOC_PROCESSORS.equals( option ) ) {
            return 2;
        }
        
        return 0;
    }

    /**
     * Validate options.
     * 
     * @param options
     * @param reporter
     * @return if the specified options are valid
     */
    public static boolean validOptions( String[][] options, DocErrorReporter reporter ) {
        return validOption( OPTION_OUTPUT, "<path-to-file>", options, reporter )
            && validOption( OPTION_CLASSPATH, "<path>", options, reporter );
    }

    private static boolean validOption( String optionName,
            String reportOptionName,
            String[][] options,
            DocErrorReporter reporter ) {
        final String option = getOptionArg( options, optionName );

        final boolean foundOption = option != null && option.trim().length() > 0;
        if ( !foundOption ) {
            reporter.printError( optionName + " "+ reportOptionName +" must be specified." );
        }
        return foundOption;
    }

    private static String getOptionArg( String[][] options, String option ) {
        
        for ( int i = 0; i < options.length; i++ ) {
            String[] opt = options[i];

            if ( opt[0].equals( option ) ) {
                return opt[1];
            }
        }
        
        return null;
    }
    
    static class Loader extends URLClassLoader {
        
        public Loader(String[] paths, ClassLoader parent) {
            super(getURLs(paths), parent);
        }
        
        Loader(String[] paths) {
            super(getURLs(paths));
        }

        private static URL[] getURLs(String[] paths) {
            final List<URL> urls = new ArrayList<URL>();
            for (String path: paths) {
                try {
                    urls.add(new File(path).toURI().toURL());
                } catch (MalformedURLException e) {
                    throw new RuntimeException(e);
                }
            }
            final URL[] us = urls.toArray(new URL[0]);
            return us;
        }
        
    }
    
}
