/*******************************************************************************
 * Copyright (c) 2014, 2016  Oracle and/or its affiliates. All rights reserved.
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v1.0 and Eclipse Distribution License v. 1.0
 * which accompanies this distribution.
 * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html
 * and the Eclipse Distribution License is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * Contributors:
 *     Dmitry Kornilov - Initial implementation
 ******************************************************************************/
package org.eclipse.persistence.jpa.rs.resources;

import org.eclipse.persistence.descriptors.ClassDescriptor;
import org.eclipse.persistence.eis.mappings.EISCompositeCollectionMapping;
import org.eclipse.persistence.internal.expressions.ConstantExpression;
import org.eclipse.persistence.internal.expressions.MapEntryExpression;
import org.eclipse.persistence.internal.jpa.rs.metadata.model.LinkV2;
import org.eclipse.persistence.internal.jpa.rs.metadata.model.v2.MetadataCatalog;
import org.eclipse.persistence.internal.jpa.rs.metadata.model.v2.Property;
import org.eclipse.persistence.internal.jpa.rs.metadata.model.v2.Reference;
import org.eclipse.persistence.internal.jpa.rs.metadata.model.v2.Resource;
import org.eclipse.persistence.internal.jpa.rs.metadata.model.v2.ResourceSchema;
import org.eclipse.persistence.internal.queries.ReportItem;
import org.eclipse.persistence.internal.sessions.AbstractSession;
import org.eclipse.persistence.jpa.rs.PersistenceContext;
import org.eclipse.persistence.jpa.rs.exceptions.JPARSException;
import org.eclipse.persistence.jpa.rs.features.ItemLinksBuilder;
import org.eclipse.persistence.jpa.rs.resources.common.AbstractResource;
import org.eclipse.persistence.jpa.rs.util.HrefHelper;
import org.eclipse.persistence.jpa.rs.util.JPARSLogger;
import org.eclipse.persistence.jpa.rs.util.StreamingOutputMarshaller;
import org.eclipse.persistence.mappings.CollectionMapping;
import org.eclipse.persistence.mappings.DatabaseMapping;
import org.eclipse.persistence.mappings.ForeignReferenceMapping;
import org.eclipse.persistence.queries.DatabaseQuery;
import org.eclipse.persistence.queries.ReportQuery;
import org.eclipse.persistence.sessions.DatabaseRecord;

import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import javax.xml.bind.JAXBException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.eclipse.persistence.jpa.rs.resources.common.AbstractResource.SERVICE_VERSION_FORMAT;

/**
 * JPARS 2.0 metadata catalog. Resource metadata and schemas.
 *
 * @author Dmitry Kornilov
 * @since EclipseLink 2.6.0.
 */
@Produces({ MediaType.APPLICATION_JSON })
@Consumes({ MediaType.APPLICATION_JSON })
@Path("/{version : " + SERVICE_VERSION_FORMAT + "}/{context}/metadata-catalog/")
public class MetadataResource extends AbstractResource {
    private static final String CLASS_NAME = MetadataResource.class.getName();
    private static final Map<Class<?>, String> PRIMITIVE_TO_JSON = new HashMap<Class<?>, String>();

    static {
        PRIMITIVE_TO_JSON.put(boolean.class, "boolean");
        PRIMITIVE_TO_JSON.put(byte.class, "number");
        PRIMITIVE_TO_JSON.put(char.class, "string");
        PRIMITIVE_TO_JSON.put(double.class, "number");
        PRIMITIVE_TO_JSON.put(float.class, "number");
        PRIMITIVE_TO_JSON.put(int.class, "integer");
        PRIMITIVE_TO_JSON.put(long.class, "integer");
        PRIMITIVE_TO_JSON.put(short.class, "number");
        PRIMITIVE_TO_JSON.put(short.class, "number");
    }

    /**
     * Returns metadata catalog.
     */
    @GET
    public Response getMetadataCatalog(@PathParam("version") String version,
                                       @PathParam("context") String persistenceUnit,
                                       @Context HttpHeaders httpHeaders,
                                       @Context UriInfo uriInfo) {
        setRequestUniqueId();
        return buildMetadataCatalogResponse(version, persistenceUnit, httpHeaders, uriInfo);
    }

    /**
     * Returns entity metadata if accepted media type is 'application/json' or entity schema if
     * accepted media type is 'application/schema+json'.
     */
    @GET
    @Produces({ MediaType.APPLICATION_JSON, AbstractResource.APPLICATION_SCHEMA_JSON })
    @Consumes({ MediaType.APPLICATION_JSON, AbstractResource.APPLICATION_SCHEMA_JSON })
    @Path("entity/{entityName}")
    public Response getEntityResource(@PathParam("version") String version,
                                      @PathParam("context") String persistenceUnit,
                                      @PathParam("entityName") String entityName,
                                      @Context HttpHeaders httpHeaders,
                                      @Context UriInfo uriInfo) {
        setRequestUniqueId();
        final MediaType mediaType = StreamingOutputMarshaller.mediaType(httpHeaders.getAcceptableMediaTypes());

        // Return schema if application.schema+json media type is requested otherwise return entity metadata
        if (mediaType.equals(AbstractResource.APPLICATION_SCHEMA_JSON_TYPE)) {
            return buildEntitySchemaResponse(version, persistenceUnit, entityName, uriInfo);
        } else {
            return buildEntityMetadataResponse(version, persistenceUnit, entityName, httpHeaders, uriInfo);
        }
    }

    /**
     * Returns query metadata if accepted media type is 'application/json' or entity schema if
     * accepted media type is 'application/schema+json'.
     */
    @GET
    @Produces({ MediaType.APPLICATION_JSON, AbstractResource.APPLICATION_SCHEMA_JSON })
    @Consumes({ MediaType.APPLICATION_JSON, AbstractResource.APPLICATION_SCHEMA_JSON })
    @Path("query/{queryName}")
    public Response getQueryResource(@PathParam("version") String version,
                                     @PathParam("context") String persistenceUnit,
                                     @PathParam("queryName") String queryName,
                                     @Context HttpHeaders httpHeaders,
                                     @Context UriInfo uriInfo) {
        setRequestUniqueId();
        final MediaType mediaType = StreamingOutputMarshaller.mediaType(httpHeaders.getAcceptableMediaTypes());

        // Return schema if application.schema+json media type is requested otherwise return entity metadata
        if (mediaType.equals(AbstractResource.APPLICATION_SCHEMA_JSON_TYPE)) {
            return buildQuerySchemaResponse(version, persistenceUnit, queryName, uriInfo);
        } else {
            return buildQueryMetadataResponse(version, persistenceUnit, queryName, httpHeaders, uriInfo);
        }
    }

    private Response buildMetadataCatalogResponse(String version, String persistenceUnit, HttpHeaders httpHeaders, UriInfo uriInfo) {
        JPARSLogger.entering(CLASS_NAME, "buildMetadataCatalogResponse", new Object[]{"GET", version, persistenceUnit, uriInfo.getRequestUri().toASCIIString()});

        final String result;
        try {
            final PersistenceContext context = getPersistenceContext(persistenceUnit, null, uriInfo.getBaseUri(), version, null);
            final MetadataCatalog catalog = buildMetadataCatalog(context);
            final String mediaType = StreamingOutputMarshaller.mediaType(httpHeaders.getAcceptableMediaTypes()).toString();
            result = marshallMetadata(catalog, mediaType);
        } catch (JAXBException e) {
            throw JPARSException.exceptionOccurred(e);
        }
        return Response.ok(new StreamingOutputMarshaller(null, result, httpHeaders.getAcceptableMediaTypes())).build();
    }

    private Response buildEntityMetadataResponse(String version, String persistenceUnit, String entityName, HttpHeaders httpHeaders, UriInfo uriInfo) {
        JPARSLogger.entering(CLASS_NAME, "buildEntityMetadataResponse", new Object[]{"GET", version, persistenceUnit, entityName, uriInfo.getRequestUri().toASCIIString()});

        final String result;
        try {
            final PersistenceContext context = getPersistenceContext(persistenceUnit, null, uriInfo.getBaseUri(), version, null);
            final ClassDescriptor descriptor = context.getServerSession().getDescriptorForAlias(entityName);
            if (descriptor == null) {
                JPARSLogger.error(context.getSessionLog(), "jpars_could_not_find_entity_type", new Object[] { entityName, persistenceUnit });
                throw JPARSException.classOrClassDescriptorCouldNotBeFoundForEntity(entityName, persistenceUnit);
            } else {
                final String mediaType = StreamingOutputMarshaller.mediaType(httpHeaders.getAcceptableMediaTypes()).toString();
                final Resource resource = buildEntityMetadata(context, descriptor);
                result = marshallMetadata(resource, mediaType);
            }
        } catch (JAXBException e) {
            throw JPARSException.exceptionOccurred(e);
        }
        return Response.ok(new StreamingOutputMarshaller(null, result, httpHeaders.getAcceptableMediaTypes())).build();
    }

    private Response buildQueryMetadataResponse(String version, String persistenceUnit, String queryName, HttpHeaders httpHeaders, UriInfo uriInfo) {
        JPARSLogger.entering(CLASS_NAME, "buildQueryMetadataResponse", new Object[]{"GET", version, persistenceUnit, queryName, uriInfo.getRequestUri().toASCIIString()});

        final String result;
        try {
            final PersistenceContext context = getPersistenceContext(persistenceUnit, null, uriInfo.getBaseUri(), version, null);

            // We need to make sure that query with given name exists
            final DatabaseQuery query = context.getServerSession().getQuery(queryName);
            if (query == null) {
                JPARSLogger.error(context.getSessionLog(), "jpars_could_not_find_query", new Object[] {queryName, persistenceUnit});
                throw JPARSException.responseCouldNotBeBuiltForNamedQueryRequest(queryName, context.getName());
            }

            final String mediaType = StreamingOutputMarshaller.mediaType(httpHeaders.getAcceptableMediaTypes()).toString();
            final Resource resource = buildQueryMetadata(context, query);
            result = marshallMetadata(resource, mediaType);
        } catch (JAXBException e) {
            throw JPARSException.exceptionOccurred(e);
        }
        return Response.ok(new StreamingOutputMarshaller(null, result, httpHeaders.getAcceptableMediaTypes())).build();
    }

    private Response buildEntitySchemaResponse(String version, String persistenceUnit, String entityName, UriInfo uriInfo) {
        JPARSLogger.entering(CLASS_NAME, "buildEntitySchemaResponse", new Object[]{"GET", version, persistenceUnit, uriInfo.getRequestUri().toASCIIString()});

        final String result;
        try {
            final PersistenceContext context = getPersistenceContext(persistenceUnit, null, uriInfo.getBaseUri(), version, null);
            final ClassDescriptor descriptor = context.getServerSession().getDescriptorForAlias(entityName);
            if (descriptor == null) {
                JPARSLogger.error(context.getSessionLog(), "jpars_could_not_find_entity_type", new Object[] { entityName, persistenceUnit });
                throw JPARSException.classOrClassDescriptorCouldNotBeFoundForEntity(entityName, persistenceUnit);
            } else {
                final ResourceSchema schema = new ResourceSchema();
                schema.setTitle(descriptor.getAlias());
                schema.setSchema(HrefHelper.buildEntityMetadataHref(context, descriptor.getAlias()) + "#");
                schema.addAllOf(new Reference(HrefHelper.buildBaseRestSchemaRef("#/singularResource")));

                // Properties
                for (DatabaseMapping databaseMapping : descriptor.getMappings()) {
                    schema.addProperty(databaseMapping.getAttributeName(), buildProperty(context, databaseMapping));
                }

                // Links
                final String instancesHref = HrefHelper.buildEntityDescribesHref(context, descriptor.getAlias());
                schema.setLinks((new ItemLinksBuilder())
                        .addDescribedBy(HrefHelper.buildEntityMetadataHref(context, descriptor.getAlias()))
                        .addFind(instancesHref + "/{primaryKey}")
                        .addCreate(instancesHref)
                        .addUpdate(instancesHref)
                        .addDelete(instancesHref + "/{primaryKey}")
                        .getList());

                result = marshallMetadata(schema, MediaType.APPLICATION_JSON);
            }
        } catch (JAXBException e) {
            throw JPARSException.exceptionOccurred(e);
        }
        return Response.ok(new StreamingOutputMarshaller(null, result, AbstractResource.APPLICATION_SCHEMA_JSON_TYPE)).build();
    }

    private Response buildQuerySchemaResponse(String version, String persistenceUnit, String queryName, UriInfo uriInfo) {
        JPARSLogger.entering(CLASS_NAME, "buildQuerySchemaResponse", new Object[]{"GET", version, persistenceUnit, uriInfo.getRequestUri().toASCIIString()});

        final String result;
        try {
            final PersistenceContext context = getPersistenceContext(persistenceUnit, null, uriInfo.getBaseUri(), version, null);

            // We need to make sure that query with given name exists
            final DatabaseQuery query = context.getServerSession().getQuery(queryName);
            if (query == null) {
                JPARSLogger.error(context.getSessionLog(), "jpars_could_not_find_query", new Object[] {queryName, persistenceUnit});
                throw JPARSException.responseCouldNotBeBuiltForNamedQueryRequest(queryName, context.getName());
            }

            final ResourceSchema querySchema = buildQuerySchema(context, query);
            result = marshallMetadata(querySchema, MediaType.APPLICATION_JSON);
        } catch (JAXBException e) {
            throw JPARSException.exceptionOccurred(e);
        }
        return Response.ok(new StreamingOutputMarshaller(null, result, AbstractResource.APPLICATION_SCHEMA_JSON_TYPE)).build();
    }

    private MetadataCatalog buildMetadataCatalog(PersistenceContext context) {
        final MetadataCatalog result = new MetadataCatalog();

        // Entities
        final Map<Class, ClassDescriptor> descriptors = context.getServerSession().getDescriptors();
        for (Class clazz : descriptors.keySet()) {
            ClassDescriptor descriptor = descriptors.get(clazz);

            // Skip embeddables
            if (!descriptor.isAggregateDescriptor()) {
                result.addResource(buildEntityMetadata(context, descriptor));
            }
        }

        // Queries
        final Map<String, List<DatabaseQuery>> allQueries = context.getServerSession().getQueries();
        for (List<DatabaseQuery> databaseQueries : allQueries.values()) {
            if (databaseQueries != null) {
                for (DatabaseQuery query : databaseQueries) {
                    if (query.getReferenceClassName() != null) {
                        result.addResource(buildQueryMetadata(context, query));
                    }
                }
            }
        }

        final String href = HrefHelper.buildMetadataCatalogHref(context);
        final List<LinkV2> links = (new ItemLinksBuilder())
                .addCanonical(href)
                .getList();

        result.setLinks(links);
        return result;
    }

    private Resource buildEntityMetadata(PersistenceContext context, ClassDescriptor descriptor) {
        final Resource resource = new Resource();
        resource.setName(descriptor.getAlias());

        final String metadataHref = HrefHelper.buildEntityMetadataHref(context, descriptor.getAlias());
        final List<LinkV2> links = (new ItemLinksBuilder())
                .addAlternate(metadataHref)
                .addCanonical(metadataHref, MediaType.APPLICATION_JSON)
                .addDescribes(HrefHelper.buildEntityDescribesHref(context, descriptor.getAlias()))
                .getList();

        resource.setLinks(links);
        return resource;
    }

    private Resource buildQueryMetadata(PersistenceContext context, DatabaseQuery query) {
        final Resource resource = new Resource();
        resource.setName(query.getName());

        final String metadataHref = HrefHelper.buildQueryMetadataHref(context, query.getName());
        final List<LinkV2> links = (new ItemLinksBuilder())
                .addAlternate(metadataHref)
                .addCanonical(metadataHref, MediaType.APPLICATION_JSON)
                .addDescribes(HrefHelper.buildQueryDescribesHref(context, query.getName()))
                .getList();

        resource.setLinks(links);
        return resource;
    }

    private ResourceSchema buildQuerySchema(PersistenceContext context, DatabaseQuery query) {
        final ResourceSchema schema = new ResourceSchema();
        schema.setTitle(query.getName());
        schema.setSchema(HrefHelper.buildQueryMetadataHref(context, query.getName()) + "#");
        schema.addAllOf(new Reference(HrefHelper.buildBaseRestSchemaRef("#/collectionBaseResource")));

        // Link
        final String method = query.isReadQuery() ? "GET" : "POST";
        schema.setLinks((new ItemLinksBuilder())
                .addExecute(HrefHelper.buildQueryHref(context, query.getName(), getQueryParamString(query)), method)
                .getList());

        // Definitions
        if (query.isReportQuery()) {
            // In case of report query we need to define a returned type
            final ResourceSchema returnType = new ResourceSchema();

            query.checkPrepare((AbstractSession) context.getServerSession(), new DatabaseRecord());
            for (ReportItem item : ((ReportQuery) query).getItems()) {
                final Property property;
                if (item.getMapping() != null) {
                    if (item.getAttributeExpression() != null && item.getAttributeExpression().isMapEntryExpression()) {
                        if (((MapEntryExpression)item.getAttributeExpression()).shouldReturnMapEntry()) {
                            property = buildProperty(context, Map.Entry.class);
                        } else {
                            property = buildProperty(context, ((Class<?>) item.getMapping().getContainerPolicy().getKeyType()));
                        }
                    } else {
                        property = buildProperty(context, item.getMapping().getAttributeClassification());
                    }
                } else if (item.getResultType() != null) {
                    property = buildProperty(context, item.getResultType());
                } else if (item.getDescriptor() != null) {
                    property = buildProperty(context, item.getDescriptor().getJavaClass());
                } else if (item.getAttributeExpression() != null && item.getAttributeExpression().isConstantExpression()) {
                    property = buildProperty(context, ((ConstantExpression) item.getAttributeExpression()).getValue().getClass());
                } else {
                    // Use Object.class by default.
                    property = buildProperty(context, Object.class);
                }
                returnType.addProperty(item.getName(), property);
            }
            schema.addDefinition("result", returnType);

            final Property items = new Property();
            items.setType("array");
            items.setItems(new Property("#/definitions/result"));
            schema.addProperty("items", items);
        } else {
            // Read all query. Each item is an entity. Make a JSON pointer.
            if (query.getReferenceClassName() != null) {
                final Property items = new Property();
                items.setType("array");
                items.setItems(new Property(HrefHelper.buildEntityMetadataHref(context, query.getReferenceClass().getSimpleName()) + "#"));
                schema.addProperty("items", items);
            }
        }

        return schema;
    }

    private String getQueryParamString(DatabaseQuery query) {
        final StringBuilder queryParams = new StringBuilder();
        for (String arg : query.getArguments()) {
            queryParams.append(";");
            queryParams.append(arg).append("={").append(arg).append("}");
        }
        return queryParams.toString();
    }

    private Property buildProperty(PersistenceContext context, DatabaseMapping mapping) {
        if (mapping.isCollectionMapping()) {
            final Property property = new Property();
            property.setType("array");
            property.setItems(buildProperty(context, getCollectionGenericClass(mapping)));
            return property;
        } else if (mapping.isForeignReferenceMapping()) {
            final ForeignReferenceMapping foreignReferenceMapping = (ForeignReferenceMapping)mapping;
            final String href = HrefHelper.buildEntityMetadataHref(context, foreignReferenceMapping.getReferenceClass().getSimpleName() + "#");
            return new Property(href);
        } else {
            return buildProperty(context, mapping.getAttributeClassification());
        }
    }

    private Property buildProperty(PersistenceContext context, Class clazz) {
        final Property property = new Property();
        if (context.getServerSession().getDescriptorForAlias(clazz.getSimpleName()) != null) {
            property.setRef(HrefHelper.buildEntityMetadataHref(context, clazz.getSimpleName()) + "#");
        } else if (Number.class.isAssignableFrom(clazz)) {
            property.setType("number");
        } else if (Boolean.class.equals(clazz)) {
            property.setType("boolean");
        } else if (String.class.equals(clazz)) {
            property.setType("string");
        } else if (Collection.class.isAssignableFrom(clazz)) {
            property.setType("array");
        } else if (clazz.isPrimitive()) {
            property.setType(PRIMITIVE_TO_JSON.get(clazz));
        } else {
            property.setType("object");
        }

        return property;
    }

    private Class getCollectionGenericClass(DatabaseMapping mapping) {
        Class collectionName = null;
        if (mapping.isEISMapping()) {
            final EISCompositeCollectionMapping collectionMapping = (EISCompositeCollectionMapping) mapping;
            if (collectionMapping.getReferenceClass() != null) {
                collectionName = collectionMapping.getReferenceClass();
            }
            if ((collectionName == null) && (collectionMapping.getAttributeClassification() != null)) {
                collectionName = collectionMapping.getAttributeClassification();
            }
        } else {
            final CollectionMapping collectionMapping = (CollectionMapping) mapping;
            if (collectionMapping.getReferenceClass() != null) {
                collectionName = collectionMapping.getReferenceClass();
            }
            if ((collectionName == null) && (collectionMapping.getAttributeClassification() != null)) {
                collectionName = collectionMapping.getAttributeClassification();
            }
        }

        return collectionName;
    }
}
