/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.unomi.rest.endpoints;

import org.apache.commons.lang.StringUtils;
import org.apache.cxf.rs.security.cors.CrossOriginResourceSharing;
import org.apache.unomi.api.*;
import org.apache.unomi.api.conditions.Condition;
import org.apache.unomi.api.query.Query;
import org.apache.unomi.api.services.EventService;
import org.apache.unomi.api.services.ProfileService;
import org.apache.unomi.api.services.SegmentService;
import org.apache.unomi.persistence.spi.CustomObjectMapper;
import org.apache.unomi.rest.service.impl.LocalizationHelper;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jws.WebMethod;
import javax.jws.WebService;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;

/**
 * A JAX-RS endpoint to manage {@link Profile}s and {@link Persona}s.
 */
@WebService
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@CrossOriginResourceSharing(
        allowAllOrigins = true,
        allowCredentials = true
)
@Path("/profiles")
@Component(service=ProfileServiceEndPoint.class,property = "osgi.jaxrs.resource=true")
public class ProfileServiceEndPoint {

    private static final Logger logger = LoggerFactory.getLogger(ProfileServiceEndPoint.class.getName());

    @Reference
    private ProfileService profileService;

    @Reference
    private EventService eventService;

    @Reference
    private SegmentService segmentService;

    @Reference
    private LocalizationHelper localizationHelper;

    public ProfileServiceEndPoint() {
        logger.info("Initializing profile service endpoint...");
    }

    @WebMethod(exclude = true)
    public void setProfileService(ProfileService profileService) {
        this.profileService = profileService;
    }

    @WebMethod(exclude = true)
    public void setEventService(EventService eventService) {
        this.eventService = eventService;
    }

    @WebMethod(exclude = true)
    public void setSegmentService(SegmentService segmentService) {
        this.segmentService = segmentService;
    }

    @WebMethod(exclude = true)
    public void setLocalizationHelper(LocalizationHelper localizationHelper) {
        this.localizationHelper = localizationHelper;
    }

    /**
     * Retrieves the number of unique profiles.
     *
     * @return the number of unique profiles.
     */
    @GET
    @Path("/count")
    public long getAllProfilesCount() {
        return profileService.getAllProfilesCount();
    }

    /**
     * Retrieves profiles matching the specified query.
     *
     * @param query a {@link Query} specifying which elements to retrieve
     * @return a {@link PartialList} of profiles instances matching the specified query
     */
    @POST
    @Path("/search")
    public PartialList<Profile> getProfiles(Query query) {
        return profileService.search(query, Profile.class);
    }

    /**
     * Retrieves an export of profiles matching the specified query as a downloadable file using the comma-separated values (CSV) format.
     *
     * @param query a String JSON representation of the query the profiles to export should match
     * @return a Response object configured to allow caller to download the CSV export file
     */
    @GET
    @Path("/export")
    @Produces("text/csv")
    public Response getExportProfiles(@QueryParam("query") String query) {
        try {
            return exportProfiles(CustomObjectMapper.getObjectMapper().readValue(query, Query.class));
        } catch (IOException e) {
            logger.error(e.getMessage(), e);
            return Response.serverError().build();
        }
    }

    /**
     * A version of {@link #getExportProfiles(String)} suitable to be called from an HTML form.
     *
     * @param query a form-encoded representation of the query the profiles to export should match
     * @return a Response object configured to allow caller to download the CSV export file
     */
    @GET
    @Path("/export")
    @Produces("text/csv")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    public Response formExportProfiles(@FormParam("query") String query) {
        try {
            return exportProfiles(CustomObjectMapper.getObjectMapper().readValue(query, Query.class));
        } catch (IOException e) {
            logger.error(e.getMessage(), e);
            return Response.serverError().build();
        }
    }

    /**
     * Retrieves an export of profiles matching the specified query as a downloadable file using the comma-separated values (CSV) format.
     *
     * @param query a String JSON representation of the query the profiles to export should match
     * @return a Response object configured to allow caller to download the CSV export file
     */
    @POST
    @Path("/export")
    @Produces("text/csv")
    public Response exportProfiles(Query query) {
        String toCsv = profileService.exportProfilesPropertiesToCsv(query);
        Response.ResponseBuilder response = Response.ok(toCsv);
        response.header("Content-Disposition",
                "attachment; filename=Profiles_export_" + new SimpleDateFormat("yyyy-MM-dd-HH-mm").format(new Date()) + ".csv");
        return response.build();
    }

    /**
     * Update all profiles in batch according to the specified {@link BatchUpdate}
     *
     * @param update the batch update specification
     */
    @POST
    @Path("/batchProfilesUpdate")
    public void batchProfilesUpdate(BatchUpdate update) {
        profileService.batchProfilesUpdate(update);
    }

    /**
     * Retrieves the profile identified by the specified identifier.
     *
     * @param profileId the identifier of the profile to retrieve
     * @return the profile identified by the specified identifier or {@code null} if no such profile exists
     */
    @GET
    @Path("/{profileId}")
    public Profile load(@PathParam("profileId") String profileId) {
        return profileService.load(profileId);
    }

    /**
     * Saves the specified profile in the context server, sending a {@code profileUpdated} event.
     *
     * @param profile the profile to be saved
     * @return the newly saved profile
     */
    @POST
    @Path("/")
    public Profile save(Profile profile) {
        Profile savedProfile = profileService.saveOrMerge(profile);
        if (savedProfile != null) {
            Event profileUpdated = new Event("profileUpdated", null, savedProfile, null, null, savedProfile, new Date());
            profileUpdated.setPersistent(false);
            int changes = eventService.send(profileUpdated);
            if ((changes & EventService.PROFILE_UPDATED) == EventService.PROFILE_UPDATED) {
                profileService.save(savedProfile);
            }
        }
        return savedProfile;
    }

    /**
     * Removes the profile (or persona if the {@code persona} query parameter is set to {@code true}) identified by the specified identifier.
     *
     * @param profileId the identifier of the profile or persona to delete
     * @param persona   {@code true} if the specified identifier is supposed to refer to a persona, {@code false} if it is supposed to refer to a profile
     */
    @DELETE
    @Path("/{profileId}")
    public void delete(@PathParam("profileId") String profileId, @QueryParam("persona") @DefaultValue("false") boolean persona) {
        profileService.delete(profileId, persona);
    }

    /**
     * Retrieves the sessions associated with the profile identified by the specified identifier that match the specified query (if specified), ordered according to the specified
     * {@code sortBy} String and and paged: only {@code size} of them are retrieved, starting with the {@code offset}-th one.
     *
     * TODO: use a Query object instead of distinct parameter?
     *
     * @param profileId the identifier of the profile we want to retrieve sessions from
     * @param query     a String of text used for fulltext filtering which sessions we are interested in or {@code null} (or an empty String) if we want to retrieve all sessions
     * @param offset    zero or a positive integer specifying the position of the first session in the total ordered collection of matching sessions
     * @param size      a positive integer specifying how many matching sessions should be retrieved or {@code -1} if all of them should be retrieved
     * @param sortBy    an optional ({@code null} if no sorting is required) String of comma ({@code ,}) separated property names on which ordering should be performed, ordering
     *                  elements according to the property order in the
     *                  String, considering each in turn and moving on to the next one in case of equality of all preceding ones. Each property name is optionally followed by
     *                  a column ({@code :}) and an order specifier: {@code asc} or {@code desc}.
     * @return a {@link PartialList} of matching sessions
     */
    @GET
    @Path("/{profileId}/sessions")
    public PartialList<Session> getProfileSessions(@PathParam("profileId") String profileId,
                                                   @QueryParam("q") String query,
                                                   @QueryParam("offset") @DefaultValue("0") int offset,
                                                   @QueryParam("size") @DefaultValue("50") int size,
                                                   @QueryParam("sort") String sortBy) {
        return profileService.getProfileSessions(profileId, query, offset, size, sortBy);
    }

    /**
     * Retrieves the list of segment metadata for the segments the specified profile is a member of.
     *
     * @param profileId the identifier of the profile for which we want to retrieve the segment metadata
     * @return the (possibly empty) list of segment metadata for the segments the specified profile is a member of
     */
    @GET
    @Path("/{profileId}/segments")
    public List<Metadata> getProfileSegments(@PathParam("profileId") String profileId) {
        Profile profile = profileService.load(profileId);
        return segmentService.getSegmentMetadatasForProfile(profile);
    }

    /**
     * TODO
     *
     * @param fromPropertyTypeId fromPropertyTypeId
     * @return property type mapping
     */
    @GET
    @Path("/properties/mappings/{fromPropertyTypeId}")
    public String getPropertyTypeMapping(@PathParam("fromPropertyTypeId") String fromPropertyTypeId) {
        return profileService.getPropertyTypeMapping(fromPropertyTypeId);
    }

    /**
     * Retrieves {@link Persona} matching the specified query.
     *
     * @param query a {@link Query} specifying which elements to retrieve
     * @return a {@link PartialList} of Persona instances matching the specified query
     */
    @POST
    @Path("/personas/search")
    public PartialList<Persona> getPersonas(Query query) {
        return profileService.search(query, Persona.class);
    }

    /**
     * Retrieves the {@link Persona} identified by the specified identifier.
     *
     * @param personaId the identifier of the persona to retrieve
     * @return the persona identified by the specified identifier or {@code null} if no such persona exists
     */
    @GET
    @Path("/personas/{personaId}")
    public Persona loadPersona(@PathParam("personaId") String personaId) {
        return profileService.loadPersona(personaId);
    }

    /**
     * Retrieves the persona identified by the specified identifier and all its associated sessions
     *
     * @param personaId the identifier of the persona to retrieve
     * @return a {@link PersonaWithSessions} instance with the persona identified by the specified identifier and all its associated sessions
     */
    @GET
    @Path("/personasWithSessions/{personaId}")
    public PersonaWithSessions loadPersonaWithSessions(@PathParam("personaId") String personaId) {
        return profileService.loadPersonaWithSessions(personaId);
    }

    /**
     * Save the posted persona with its sessions
     *
     * @param personaWithSessions the persona to save with its sessions.
     * @return a {@link PersonaWithSessions} instance with the persona identified by the specified identifier and all its associated sessions
     */
    @POST
    @Path("/personasWithSessions")
    public PersonaWithSessions savePersonaWithSessions(PersonaWithSessions personaWithSessions) {
        return profileService.savePersonaWithSessions(personaWithSessions);
    }

    /**
     * Persists the specified {@link Persona} in the context server.
     *
     * @param persona the persona to persist
     * @return the newly persisted persona
     */
    @POST
    @Path("/personas")
    public Persona savePersona(Persona persona) {
        return profileService.savePersona(persona);
    }

    /**
     * Removes the persona identified by the specified identifier.
     *
     * @param personaId the identifier of the persona to delete
     * @param persona   {@code true} if the specified identifier is supposed to refer to a persona, {@code false} if it is supposed to refer to a profile
     */
    @DELETE
    @Path("/personas/{personaId}")
    public void deletePersona(@PathParam("personaId") String personaId, @QueryParam("persona") @DefaultValue("true") boolean persona) {
        profileService.delete(personaId, persona);
    }

    /**
     * Creates a persona with the specified identifier and automatically creates an associated session with it.
     *
     * @param personaId the identifier to use for the new persona
     * @return the newly created persona
     */
    @PUT
    @Path("/personas/{personaId}")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    public Persona createPersona(@PathParam("personaId") String personaId) {
        return profileService.createPersona(personaId);
    }

    /**
     * Retrieves the sessions associated with the persona identified by the specified identifier, ordered according to the specified {@code sortBy} String and and paged: only
     * {@code size} of them are retrieved, starting with the {@code offset}-th one.
     *
     * @param personaId the persona id
     * @param offset    zero or a positive integer specifying the position of the first session in the total ordered collection of matching sessions
     * @param size      a positive integer specifying how many matching sessions should be retrieved or {@code -1} if all of them should be retrieved
     * @param sortBy    an optional ({@code null} if no sorting is required) String of comma ({@code ,}) separated property names on which ordering should be performed, ordering
     *                  elements according to the property order in the
     *                  String, considering each in turn and moving on to the next one in case of equality of all preceding ones. Each property name is optionally followed by
     *                  a column ({@code :}) and an order specifier: {@code asc} or {@code desc}.
     * @return a {@link PartialList} of sessions for the persona identified by the specified identifier
     */
    @GET
    @Path("/personas/{personaId}/sessions")
    public PartialList<Session> getPersonaSessions(@PathParam("personaId") String personaId,
                                                   @QueryParam("offset") @DefaultValue("0") int offset,
                                                   @QueryParam("size") @DefaultValue("50") int size,
                                                   @QueryParam("sort") String sortBy) {
        return profileService.getPersonaSessions(personaId, offset, size, sortBy);
    }

    /**
     * Retrieves the session identified by the specified identifier.
     *
     * @param sessionId the identifier of the session to be retrieved
     * @return the session identified by the specified identifier
     * @throws ParseException if the date hint cannot be parsed as a proper {@link Date} object
     */
    @GET
    @Path("/sessions/{sessionId}")
    public Session loadSession(@PathParam("sessionId") String sessionId) throws ParseException {
        return profileService.loadSession(sessionId);
    }
    
    /**
     * Saves the specified session.
     *
     * @param session the session to be saved
     * @return the newly saved session
     */
    @POST
    @Path("/sessions/{sessionId}")
    public Session saveSession(Session session) {
        return profileService.saveSession(session);
    }

    /**
     * Retrieves {@link Event}s for the {@link Session} identified by the provided session identifier, matching any of the provided event types,
     * ordered according to the specified {@code sortBy} String and paged: only {@code size} of them are retrieved, starting with the {@code offset}-th one.
     * If a {@code query} is provided, a full text search is performed on the matching events to further filter them.
     *
     * @param sessionId  the identifier of the user session we're considering
     * @param eventTypes an array of event type names; the events to retrieve should at least match one of these
     * @param query      a String to perform full text filtering on events matching the other conditions
     * @param offset     zero or a positive integer specifying the position of the first event in the total ordered collection of matching events
     * @param size       a positive integer specifying how many matching events should be retrieved or {@code -1} if all of them should be retrieved
     * @param sortBy     an optional ({@code null} if no sorting is required) String of comma ({@code ,}) separated property names on which ordering should be performed, ordering
     *                   elements according to the property order in
     *                   the String, considering each in turn and moving on to the next one in case of equality of all preceding ones. Each property name is optionally followed by
     *                   a column ({@code :}) and an order specifier: {@code asc} or {@code desc}.
     * @return a {@link PartialList} of matching events
     */
    @GET
    @Path("/sessions/{sessionId}/events")
    public PartialList<Event> getSessionEvents(@PathParam("sessionId") String sessionId,
                                               @QueryParam("eventTypes") String[] eventTypes,
                                               @QueryParam("q") String query,
                                               @QueryParam("offset") @DefaultValue("0") int offset,
                                               @QueryParam("size") @DefaultValue("50") int size,
                                               @QueryParam("sort") String sortBy) {
        return eventService.searchEvents(sessionId, eventTypes, query, offset, size, sortBy);
    }

    @WebMethod(exclude = true)
    public PartialList<Session> findProfileSessions(String profileId) {
        return null;
    }

    @WebMethod(exclude = true)
    public boolean matchCondition(Condition condition, Profile profile, Session session) {
        return profileService.matchCondition(condition, profile, session);
    }

    /**
     * Retrieves the existing property types for the specified type as defined by the Item subclass public field {@code ITEM_TYPE} and with the specified tag or system tag.
     *
     * TODO: move to a different class
     *
     * @param tag           the tag we're interested in
     * @param isSystemTag   if we should look in system tags instead of tags
     * @param itemType      the String representation of the item type we want to retrieve the count of, as defined by its class' {@code ITEM_TYPE} field
     * @param language      the value of the {@code Accept-Language} header to specify in which locale the properties description should be returned TODO unused
     * @param response      the http response object
     * @return all property types defined for the specified item type and with the specified tag
     * @throws IOException if there was an error sending the response
     */
    @GET
    @Path("/existingProperties")
    public Collection<PropertyType> getExistingProperties(@QueryParam("tag") String tag, @QueryParam("isSystemTag") boolean isSystemTag, @QueryParam("itemType") String itemType, @HeaderParam("Accept-Language") String language, @Context final HttpServletResponse response) throws IOException {
        if (StringUtils.isBlank(tag) || StringUtils.isBlank(itemType)) {
            response.sendError(Response.Status.BAD_REQUEST.getStatusCode(), "Missing mandatory query parameters when requesting /cxs/profiles/existingProperties, mandatory query parameters are tag and itemType");
            return null;
        }
        Set<PropertyType> properties;
        if (isSystemTag) {
            properties = profileService.getExistingProperties(tag, itemType, isSystemTag);
        } else {
            properties = profileService.getExistingProperties(tag, itemType);
        }
        return properties;
    }

    /**
     * Retrieves all known property types.
     *
     * TODO: move to a different class
     *
     * @param language the value of the {@code Accept-Language} header to specify in which locale the properties description should be returned TODO unused
     * @return a Map associating targets as keys to related {@link PropertyType}s
     */
    @GET
    @Path("/properties")
    public Map<String, Collection<PropertyType>> getPropertyTypes(@HeaderParam("Accept-Language") String language) {
        return profileService.getTargetPropertyTypes();
    }

    /**
     * Retrieves the property type associated with the specified property ID.
     *
     * TODO: move to a different class
     *
     * @param propertyId    the property ID for which we want to retrieve the associated property type
     * @param language      the value of the {@code Accept-Language} header to specify in which locale the properties description should be returned TODO unused
     * @return the property type associated with the specified ID
     */
    @GET
    @Path("/properties/{propertyId}")
    public PropertyType getPropertyType(@PathParam("propertyId") String propertyId, @HeaderParam("Accept-Language") String language) {
        return profileService.getPropertyType(propertyId);
    }

    /**
     * Retrieves all the property types associated with the specified target.
     *
     * TODO: move to a different class
     *
     * @param target   the target for which we want to retrieve the associated property types
     * @param language the value of the {@code Accept-Language} header to specify in which locale the properties description should be returned TODO unused
     * @return a collection of all the property types associated with the specified target
     */
    @GET
    @Path("/properties/targets/{target}")
    public Collection<PropertyType> getPropertyTypesByTarget(@PathParam("target") String target, @HeaderParam("Accept-Language") String language) {
        return profileService.getTargetPropertyTypes(target);
    }

    /**
     * Retrieves all property types with the specified tags.
     *
     * TODO: move to a different class
     * TODO: passing a list of tags via a comma-separated list is not very RESTful
     *
     * @param tags      a comma-separated list of tag identifiers
     * @param language  the value of the {@code Accept-Language} header to specify in which locale the properties description should be returned TODO unused
     * @return a Set of the property types with the specified tag
     */
    @GET
    @Path("/properties/tags/{tags}")
    public Collection<PropertyType> getPropertyTypeByTag(@PathParam("tags") String tags, @HeaderParam("Accept-Language") String language) {
        String[] tagsArray = tags.split(",");
        Set<PropertyType> results = new LinkedHashSet<>();
        for (String tag : tagsArray) {
            results.addAll(profileService.getPropertyTypeByTag(tag));
        }
        return results;
    }

    /**
     * Retrieves all property types with the specified tags.
     *
     * TODO: move to a different class
     * TODO: passing a list of tags via a comma-separated list is not very RESTful
     *
     * @param tags      a comma-separated list of tag identifiers
     * @param language  the value of the {@code Accept-Language} header to specify in which locale the properties description should be returned TODO unused
     * @return a Set of the property types with the specified tag
     */
    @GET
    @Path("/properties/systemTags/{tags}")
    public Collection<PropertyType> getPropertyTypeBySystemTag(@PathParam("tags") String tags, @HeaderParam("Accept-Language") String language) {
        String[] tagsArray = tags.split(",");
        Set<PropertyType> results = new LinkedHashSet<>();
        for (String tag : tagsArray) {
            results.addAll(profileService.getPropertyTypeBySystemTag(tag));
        }
        return results;
    }

    /**
     * Persists the specified property type in the context server.
     *
     * TODO: move to a different class
     *
     * @param property the property type to persist
     * @return {@code true} if the property type was properly created, {@code false} otherwise (for example, if the property type already existed
     */
    @POST
    @Path("/properties")
    public boolean setPropertyType(PropertyType property) {
        return profileService.setPropertyType(property);
    }

    /**
     * Persists the specified properties type in the context server.
     *
     * TODO: move to a different class
     *
     * @param properties the properties type to persist
     * @return {@code true} if the property type was properly created, {@code false} otherwise (for example, if the property type already existed
     */
    @POST
    @Path("/properties/bulk")
    public boolean setPropertyTypes(List<PropertyType> properties) {
        boolean saved = false;
        for (PropertyType property : properties) {
            saved |= profileService.setPropertyType(property);
        }
        return saved;
    }

    /**
     * Deletes the property type identified by the specified identifier.
     *
     * TODO: move to a different class
     *
     * @param propertyId the identifier of the property type to delete
     * @return {@code true} if the property type was properly deleted, {@code false} otherwise
     */
    @DELETE
    @Path("/properties/{propertyId}")
    public boolean deleteProperty(@PathParam("propertyId") String propertyId) {
        return profileService.deletePropertyType(propertyId);
    }

    /**
     * Retrieves sessions matching the specified query.
     *
     * @param query a {@link Query} specifying which elements to retrieve
     * @return a {@link PartialList} of sessions matching the specified query
     */
    @POST
    @Path("/search/sessions")
    public PartialList<Session> searchSession(Query query) {
        return profileService.searchSessions(query);
    }

    @POST
    @Path("/{profileId}/aliases/{aliasId}")
    public void addAliasToProfile(final @PathParam("profileId") String profileId,
                                  final @PathParam("aliasId") String aliasId,
                                  final @HeaderParam("X-Unomi-ClientId") String headerClientID) {
        String clientId = headerClientID != null ? headerClientID : "defaultClientId";
        profileService.addAliasToProfile(profileId, aliasId, clientId);
    }

    @DELETE
    @Path("/{profileId}/aliases/{aliasId}")
    public void removeAliasFromProfile(final @PathParam("profileId") String profileId,
                                       final @PathParam("aliasId") String aliasId,
                                       final @HeaderParam("X-Unomi-ClientId") String headerClientID) {
        String clientId = headerClientID != null ? headerClientID : "defaultClientId";
        profileService.removeAliasFromProfile(profileId, aliasId, clientId);
    }

    @GET
    @Path("/{profileId}/aliases")
    public PartialList<ProfileAlias> listAliasesByProfileId(final @PathParam("profileId") String profileId,
                                                            @QueryParam("offset") @DefaultValue("0") int offset,
                                                            @QueryParam("size") @DefaultValue("50") int size,
                                                            @QueryParam("sort") String sortBy) {
        return profileService.findProfileAliases(profileId, offset, size, sortBy);
    }
}
