/*
 * 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.client.hypermedia;

import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.ClientRequest;
import com.sun.jersey.api.client.ClientResponse;
import com.sun.jersey.api.client.WebResource;
import com.sun.jersey.api.client.WebResourceLinkHeaders;
import com.sun.jersey.api.representation.Form;
import com.sun.jersey.core.header.LinkHeader;
import com.sun.jersey.core.hypermedia.Action;
import com.sun.jersey.core.hypermedia.HypermediaController;
import com.sun.jersey.core.hypermedia.Name;
import com.sun.jersey.core.util.MultivaluedMapImpl;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.net.URI;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import javax.ws.rs.Consumes;
import javax.ws.rs.CookieParam;
import javax.ws.rs.FormParam;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Cookie;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.UriBuilder;
import org.jvnet.ws.wadl.Application;
import org.jvnet.ws.wadl.Param;
import org.jvnet.ws.wadl2java.Wadl2Java;
import org.jvnet.ws.wadl2java.ast.MethodNode;
import org.jvnet.ws.wadl2java.ast.RepresentationNode;
import org.jvnet.ws.wadl2java.ast.ResourceNode;

/**
 * ControllerInvocationHandler class.
 *
 * @author Santiago.PericasGeertsen@sun.com
 */
public class ControllerInvocationHandler<T> implements InvocationHandler {

    private Client client;

    private T model;

    private Class<?> modelClass;

    private Class<?> ctrlClass;

    private ClientResponse response;

    private WebResourceLinkHeaders actionHeaders;

    private Object entity;

    private MultivaluedMapImpl queryParams;

    private Map<URI, MethodNode> wadlMetadataCache;

    public ControllerInvocationHandler(Client client, T instance,
            ClientResponse response, Class<?> ctrlClass)
    {
        // Check if annotation is present in controller
        if (!ctrlClass.isAnnotationPresent(HypermediaController.class)) {
            throw new IllegalArgumentException("Hypermedia controller " +
                    "class must have @HypermediaController annotation");
        }
        this.client = client;
        this.model = instance;
        this.response = response;
        this.modelClass = instance.getClass();
        this.ctrlClass = ctrlClass;

        updateActionHeaders();
        queryParams = new MultivaluedMapImpl();
    }

    /**
     * Maps a method invocation in a controller interface to an
     * HTTP request on an action resource. Use WADL if not enough
     * static information is available for the mapping.
     */
    public Object invoke(Object o, Method method, Object[] params) 
            throws Throwable
    {
        // Clear entity reference and query values from previous call
        entity = null;
        queryParams.clear();

        // Trying to access local model (non-action method)
        Action action = method.getAnnotation(Action.class);
        if (action == null && method.getReturnType() == modelClass) {
            return model;
        }

        // Check if @Action annotation is present
        if (action == null) {
            throw new RuntimeException(
                    "Method " + method.getName() + " in "
                    + o.getClass().getInterfaces()[0].getName()
                    + " must have @Action or @Model annotation");
        }

        // Empty set of action headers?
        if (actionHeaders == null) {
            throw new RuntimeException(
                    "Action '" + action.value() + "' is not available"
                    + " in current context");
        }

        // Check if action is available in current context
        LinkHeader h = actionHeaders.getLink(action.value());
        if (h == null) {
             throw new RuntimeException(
                    "Action '" + action.value() + "' is not available"
                    + " in current context");
        }

        // If HTTP method annotation, must match 'op' in link header
        String httpMethod = getHttpMethod(ctrlClass, method);
        if (httpMethod == null) {
            httpMethod = h.getOp();
            assert httpMethod != null;
        } else if (!httpMethod.equals(h.getOp())) {
            throw new RuntimeException(
                    "HTTP method annotation " + httpMethod
                    + " in method '" + method.getName()
                    + "' does not match HTTP method "
                    + h.getOp() + " in link header");
        }

        // Create client request builder
        ClientRequest.Builder rb = ClientRequest.create();

        // Map method params to HTTP request
        mapParametersToClientRequest(rb, method, params);

        // Create URI builder and add query params
        UriBuilder ub = UriBuilder.fromUri(h.getUri());
        for (Map.Entry<String, List<String>> e : queryParams.entrySet()) {
            for (String v : e.getValue()) {
                ub.queryParam(e.getKey(), v);
            }
        }

        // Client side: produces is Accept
        Produces produces = method.getAnnotation(Produces.class);
        if (produces != null) {
            rb.accept(produces.value());
        }

        // Is there an entity?
        if (entity != null) {
            // Client side: consumes is Content-Type
            String contentType = null;
            Consumes consumes = method.getAnnotation(Consumes.class);
            if (consumes != null) {
                String[] contentTypes = consumes.value();
                if (contentTypes.length > 1) {
                    throw new RuntimeException("Annotation @Consumes in" +
                            " action '" + action.value() +
                            " ' must have a single media type");
                }
                contentType = contentTypes[0];
            } else if (entity instanceof MultivaluedMap) {
                // Guess content type if this is a Form
                contentType = MediaType.APPLICATION_FORM_URLENCODED;
            } else {
                contentType = getContentTypeFromWadl(action, method);
                assert contentType != null;
            }

            // Send HTTP request with entity
            ClientRequest cr = rb.entity(entity, contentType)
                                 .build(ub.build(), httpMethod);
            response = client.handle(cr);
        }
        else {
            // Send HTTP request without an entity
            ClientRequest cr = rb.build(ub.build(), httpMethod);
            response = client.handle(cr);
        }

        // Update action headers based on last response
        updateActionHeaders();

        // Update local model if returned
        HypermediaController hc = ctrlClass.getAnnotation(HypermediaController.class);
        assert hc != null;
        if (method.getReturnType() == hc.model()) {
            model = (T) response.getEntity(modelClass);
            response.close();
            return model;
        } else if (method.getReturnType() != void.class) {
            Object tmp = response.getEntity(method.getReturnType());
            response.close();
            return tmp;
        } else {
            return null;
        }
    }

    /**
     * Parses action headers and updates internal map.
     */
    private void updateActionHeaders() {
        actionHeaders = response.getLinks();
    }

    /**
     * Finds an annotation annotated with @HttpMethod in the
     * controller class (such as @GET, @PUT, etc.) or any of
     * its superclasses.
     */
    private String getHttpMethod(Class<?> ctrlClass, Method method) {
        // Find annotation in this class
        for (Annotation annot : method.getDeclaredAnnotations()) {
            HttpMethod httpMethod =
                    annot.annotationType().getAnnotation(HttpMethod.class);
            if (httpMethod != null) {
                return httpMethod.value();
            }
        }

        // Try super interfaces then
        Class<?>[] superInterfaces = ctrlClass.getInterfaces();
        for (Class<?> superInterface : superInterfaces) {
            for (Method superMethod : superInterface.getDeclaredMethods()) {
                if (superMethod.getName().equals(method.getName())
                        && superMethod.getReturnType() == method.getReturnType()
                        && Arrays.equals(superMethod.getParameterTypes(),
                                         method.getParameterTypes())) {
                    return getHttpMethod(superInterface, superMethod);
                }
            }
        }

        // Not found
        return null;
    }

    /**
     * Determines if an object is of any of the collection
     * types supported by JAX-RS for parameters.
     */
    private boolean isJaxrsCollectionValue(Object object) {
        return (object instanceof List || object instanceof Set
                || object instanceof SortedSet);
    }

    /**
     * Maps a method parameter in a controller interface to an
     * HTTP parameter or the entity. If not enough information
     * is available statically, use WADL to determine mapping.
     */
    private void mapParametersToClientRequest(ClientRequest.Builder rb,
            Method method, Object[] values)
    {
        // Inspect parameter annotations in method
        if (values != null) {
            Annotation[][] annotations = method.getParameterAnnotations();

            // Search for entity looking for params without annotations
            int j = 0;
            for (Object value: values) {
                if (annotations[j++].length == 0) {
                    entity = value;
                    break;
                }
            }

            // Collect all request values
            j = 0;
            for (Object value : values) {
                // If more than one annotation, report error
                if (annotations[j].length > 1) {
                    throw new IllegalArgumentException("At most one annotation"
                            + " is permitted in method '" + method.getName()
                            + "' of interface '" + ctrlClass.getName() + "'");
                }

                // Determine mapping for this value
                for (Annotation annot : annotations[j]) {
                    Class paramType = annot.annotationType();

                    // If @Name, determine param type looking at WADL
                    if (paramType == Name.class) {
                        mapParamUsingWadl(method, (Name) annot, value, rb);
                    }
                    else if (paramType == HeaderParam.class) {
                        final HeaderParam hp = (HeaderParam) annot;
                        setHeaderParam(hp.value(), value, rb);
                    }
                    else if (paramType == QueryParam.class) {
                        final QueryParam qp = (QueryParam) annot;
                        setQueryParam(qp.value(), value);
                    }
                    else if (paramType == CookieParam.class) {
                        setCookieParam(value, rb);
                    }
                    else if (paramType == FormParam.class) {
                        MultivaluedMap form = null;
                        final FormParam fp = (FormParam) annot;

                        // If entity found, check its type
                        if (entity != null) {
                            if (entity instanceof Form || entity instanceof MultivaluedMap) {
                                form = (MultivaluedMap) entity;
                            } else {
                                throw new IllegalArgumentException(
                                    "Unannotated parameter in method '"
                                    + method.getName()
                                    + "' in interface '" + ctrlClass.getName()
                                    + "' must be an instance of Form or "
                                    + " MultivaluedMap to support @FormParam");
                            }
                        } else {
                            entity = form = new Form();     // create entity
                        }

                        setFormParam(fp.value(), value, form);
                    }
                    else {
                        throw new IllegalArgumentException("Annotation "
                                + paramType.getName()
                                + " is not permitted in interface '"
                                + ctrlClass.getName() + "'");
                    }
                }

                j++;
            }
        }
    }

    /**
     * Maps a named method parameter in a controller interface
     * to an HTTP parameter or the entity using WADL.
     */
    private void mapParamUsingWadl(Method method, Name name, Object value,
            ClientRequest.Builder rb)
    {
        Action action = method.getAnnotation(Action.class);
        LinkHeader h = actionHeaders.getLink(action.value());
        MethodNode methodNode = getWadlMetadata(h);
        if (methodNode == null) {
            throw new RuntimeException("Unable to find WADL meta-data to " +
                    "map hypermedia action '" + action.value() + "'");
        }

        // Determine if this is a query param
        List<Param> params = methodNode.getQueryParameters();
        if (params != null) {
            for (Param param : params) {
                final String paramName = param.getName();
                if (paramName.equals(name.value())) {
                    setQueryParam(paramName, value);
                    return;
                }
            }
        }

        // Determine if this is a header or cookie param
        // Cookie params have name="Cookie" path="<name>"
        params = methodNode.getHeaderParameters();
        if (params != null) {
            for (Param param : params) {
                final String paramName = param.getName();
                if (paramName.equals(name.value())) {
                    setHeaderParam(paramName, value, rb);
                    return;
                } else if (paramName.equals("Cookie") &&
                          param.getPath().equals(name.value())) {
                    setCookieParam(value, rb);
                    return;
                }
            }
        }

        // Determine if this is a form param
        List<RepresentationNode> reps = methodNode.getSupportedInputs();
        if (reps != null) {
            for (RepresentationNode rep : reps) {
                // Find param in representation
                for (Param param : rep.getParam()) {
                    if (param.getName().equals(name.value())) {
                        MultivaluedMap form = null;

                        // If entity found, check its type
                        if (entity != null) {
                            if (entity instanceof Form ||
                                    entity instanceof MultivaluedMap) {
                                form = (MultivaluedMap) entity;
                            } else {
                                throw new IllegalArgumentException(
                                    "Unannotated parameter in method '"
                                    + method.getName()
                                    + "' in interface '" + ctrlClass.getName()
                                    + "' must be an instance of Form or "
                                    + " MultivaluedMap to support @FormParam");
                            }
                        } else {
                            entity = form = new Form();     // create entity
                        }

                        setFormParam(name.value(), value, form);
                        return;
                    }
                }
            }
        }

        // Couldn't map, throw exception
        throw new RuntimeException(
                "Unable to map parameter '" + name.value()
                + "' in method '" + method.getName()
                + "' of interface '" + ctrlClass.getName() + "'");
    }

    /**
     * Returns the expected content-type of an action from its
     * WADL description. It reports an error if multiple media
     * types are supported as input or if unable to determine
     * what inputs are supported.
     */
    private String getContentTypeFromWadl(Action action, Method method) {
        // Get WADL method node for this action
        LinkHeader h = actionHeaders.getLink(action.value());
        MethodNode methodNode = getWadlMetadata(h);
        if (methodNode == null) {
            throw new RuntimeException("Unable to find WADL meta-data to " +
                    "map hypermedia action '" + action.value() + "'");
        }

        // Get list of inputs and make sure it's unambiguous
        List<RepresentationNode> l = methodNode.getSupportedInputs();
        if (l == null || l.size() > 1) {
            throw new RuntimeException("Unable to determine content type"
                    + " for action '" + action.value()
                    + "' -- use @Consumes annotation");
        }
        return l.get(0).getMediaType();
    }

    /**
     * Returns a <code>MethodNode</code> that represents the meta-data
     * for the link in header <code>h</code>, or <code>null</code>
     * if no meta-data was found. Uses OPTIONS to retrieve a WADL
     * fragment for the corresponding action.
     */
    private MethodNode getWadlMetadata(LinkHeader h) {
        MethodNode result = null;

        // Meta-data in cache?
        if (wadlMetadataCache == null) {
            wadlMetadataCache = new HashMap<URI, MethodNode>();
        } else {
            result = wadlMetadataCache.get(h.getUri());
        }

        // Attempt to fetch meta-data from server
        if (result == null) {
            Wadl2Java wm = new Wadl2Java(null, null, null, false);
            try {
                WebResource r = client.resource(h.getUri());
                InputStream is = r.options(InputStream.class);

                // Requires WadlFragmentGetFilter on server side
                Application a = wm.processDescription(h.getUri(), is);
                List<ResourceNode> rs = wm.buildAst(a, h.getUri());
                ResourceNode rn = rs.get(0).getChildResources().get(0);
                
                // Find method whose operation matches the link header's
                for (MethodNode mn : rn.getMethods()) {
                    if (mn.getName().equals(h.getOp())) {
                        result = mn;
                    }
                }
            } catch (Exception ex) {
                throw new RuntimeException(ex);
            }
            wadlMetadataCache.put(h.getUri(), result);
        }
        return result;
    }

    /**
     * Sets a query param in request based on the type of value.
     */
    private void setQueryParam(String name, Object value) {
        if (isJaxrsCollectionValue(value)) {
            for (Object o : (Collection) value) {
                queryParams.add(name, o.toString());
            }
        } else {
            queryParams.add(name, value.toString());
        }
    }

    /**
     * Sets a header param in request based on the type of value.
     */
    private void setHeaderParam(String name, Object value,
            ClientRequest.Builder rb) {
        if (isJaxrsCollectionValue(value)) {
            for (Object o : (Collection) value) {
                rb.header(name, o);
            }
        } else {
            rb.header(name, value);
        }
    }

    /**
     * Sets a cookie param in request based on the type of value.
     */
    private void setCookieParam(Object value, ClientRequest.Builder rb) {
        if (isJaxrsCollectionValue(value)) {
            for (Object o : (Collection) value) {
                rb.cookie(o instanceof Cookie ? (Cookie) o
                        : Cookie.valueOf(o.toString()));
            }
        } else {
                rb.cookie(value instanceof Cookie ? (Cookie) value
                        : Cookie.valueOf(value.toString()));
        }
    }

    /**
     * Sets a form param in request based on the type of value.
     */
    private void setFormParam(String name, Object value, MultivaluedMap form) {
        if (isJaxrsCollectionValue(value)) {
            for (Object o : (Collection) value) {
                form.add(name, o);
            }
        } else {
            form.add(name, value);
        }
    }

}
