/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service.jpa;

import org.apache.commons.lang3.Validate;
import org.jasig.portlet.notice.NotificationEntry;
import org.jasig.portlet.notice.NotificationState;
import org.jasig.portlet.notice.rest.AddresseeDTO;
import org.jasig.portlet.notice.rest.EntryDTO;
import org.jasig.portlet.notice.rest.EventDTO;
import org.jasig.portlet.notice.rest.RecipientDTO;
import org.jasig.portlet.notice.rest.RecipientType;
import org.jasig.portlet.notice.util.JpaServices;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Set;

/**
 * Allows you to interact with the same data as {@link JpaNotificationService} but provides write
 * operations.
 *
 * @since 3.0
 */
@Service("jpaNotificationRestService")
public class JpaNotificationRESTService implements IJpaNotificationRESTService {

    @Autowired
    private INotificationDao notificationDao;

    @Autowired
    private INotificationDTOMapper notificationMapper;

    @Autowired
    private JpaServices jpaServices;

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    @Transactional(readOnly = true)
    public EntryDTO getNotification(long id, boolean full) {
        final JpaEntry entry = full ? notificationDao.getFullEntry(id) : notificationDao.getEntry(id);
        if (entry == null) {
            return null;
        }

        return notificationMapper.toEntry(entry);
    }

    @Override
    @Transactional(readOnly = true)
    public EntryDTO getNotification(NotificationEntry entry, boolean full) {
        EntryDTO rslt = null;  // default
        if (jpaServices.contains(entry)) {
            final String idString = entry.getId().substring(JpaNotificationService.ID_PREFIX.length());
            final long id = Long.parseLong(idString);
            rslt = getNotification(id, full);
        }
        return rslt;
    }

    @Override
    @Transactional(readOnly = true)
    public List<EntryDTO> getNotifications(Integer page, Integer pageSize) {
        final List<JpaEntry> entries = notificationDao.list(page, pageSize);

        return notificationMapper.toEntryList(entries);
    }

    @Override
    @Transactional(readOnly = true)
    public List<EntryDTO> getNotificationsBySourceAndCustomAttribute(String source, String attributeName, String attributeValue) {
        Validate.notBlank(source, "Argument 'source' cannot be blank");
        Validate.notBlank(attributeName, "Argument 'attributeName' cannot be blank");
        // Does it make sense to allow blank attributeValue?

        logger.debug("Invoking getNotificationsBySourceAndCustomAttribute with "
                + "source='{}', attributeName='{}', attributeValue='{}'",
                source, attributeName, attributeValue);

        final List<JpaEntry> entries = notificationDao.getNotificationsBySourceAndCustomAttribute(source, attributeName, attributeValue);
        logger.debug("Found the following {} entries:  {}", entries.size(), entries);

        return notificationMapper.toEntryList(entries);
    }

    @Override
    @Transactional
    public EntryDTO createNotification(EntryDTO entry) {
        Validate.isTrue(entry.getId() == 0, "Do not include an 'id' attribute when creating entries");

        final JpaEntry jpaEntry = notificationMapper.toJpaEntry(entry);
        logger.debug("notificationMapper produced the following JpaEntry:  {}", jpaEntry);

        final JpaEntry inserted = notificationDao.createOrUpdateEntry(jpaEntry);

        /*
         * Business rule:  new notifications must be ISSUED to recipients listed in the original payload.
         */
        inserted.getAddressees().forEach(addressee -> {
            if (RecipientType.INDIVIDUAL.equals(addressee.getType())) {
                addressee.getRecipients().forEach(recipient -> {
                    final EventDTO event = new EventDTO();
                    event.setUsername(recipient.getUsername());
                    event.setState(NotificationState.ISSUED);
                    createEvent(inserted.getId(), event);
                });
            }
        });

        return notificationMapper.toEntry(inserted);
    }

    @Override
    @Transactional(readOnly = true)
    public Set<AddresseeDTO> getAddressees(long notificationId) {
        final JpaEntry jpaEntry = notificationDao.getFullEntry(notificationId);
        if (jpaEntry == null) {
            return null;
        }

        return notificationMapper.toAddresseeSet(jpaEntry.getAddressees());
    }

    @Override
    @Transactional(readOnly = true)
    public AddresseeDTO getAddressee(long addresseeId) {
        final JpaAddressee addr = notificationDao.getAddressee(addresseeId);
        if (addr == null) {
            return null;
        }

        return notificationMapper.toAddressee(addr);
    }

    @Override
    @Transactional
    public AddresseeDTO createAddressee(long notificationId, AddresseeDTO addressee) {
        Validate.isTrue(addressee.getId() == 0, "Do not include an 'id' attribute when creating addressees");
        for (RecipientDTO r : addressee.getRecipients()) {
            Validate.isTrue(r.getId() == 0, "Do not include an 'id' attribute on recipients when creating addressees");
        }

        final JpaEntry entry = notificationDao.getEntry(notificationId);
        if (entry == null) {
            return null;
        }

        final JpaAddressee jpa = notificationMapper.toJpaAddressee(addressee);
        entry.addAddressee(jpa);

        final JpaAddressee persisted = notificationDao.createOrUpdateAddressee(jpa);
        return notificationMapper.toAddressee(persisted);
    }

    @Override
    @Transactional(readOnly = true)
    public List<EventDTO> getEventsByNotification(long notificationId) {
        final List<JpaEvent> events = notificationDao.getEvents(notificationId);

        return notificationMapper.toEventList(events);
    }

    /**
     * Provides a complete transaction log for a notification and a single
     * recipient in chronological order.
     */
    @Override
    @Transactional(readOnly = true)
    public List<EventDTO> getEventsByNotificationAndUser(long notificationId, String username) {
        final List<JpaEvent> events = notificationDao.getEvents(notificationId, username);

        return notificationMapper.toEventList(events);
    }

    @Override
    @Transactional(readOnly = true)
    public EventDTO getEvent(long eventId) {
        final JpaEvent event = notificationDao.getEvent(eventId);
        if (event == null) {
            return null;
        }

        return notificationMapper.toEvent(event);
    }

    @Override
    @Transactional
    public EventDTO createEvent(long notificationId, EventDTO event) {
        Validate.isTrue(event.getId() == 0, "Do not include an 'id' attribute when creating events");

        final JpaEntry entry = notificationDao.getEntry(notificationId);
        final JpaEvent jpa = notificationMapper.toJpaEvent(event);
        jpa.setEntry(entry);

        final JpaEvent jpaResult = notificationDao.createOrUpdateEvent(jpa);
        return notificationMapper.toEvent(jpaResult);
    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service.rome;

import java.io.IOException;
import java.net.URL;
import java.text.DateFormat;
import java.util.*;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.portlet.ActionRequest;
import javax.portlet.ActionResponse;
import javax.portlet.PortletPreferences;
import javax.portlet.PortletRequest;
import javax.servlet.http.HttpServletRequest;

import net.sf.ehcache.Cache;
import net.sf.ehcache.Element;

import org.apache.commons.lang3.StringUtils;
import org.jasig.portlet.notice.NotificationAttribute;
import org.jasig.portlet.notice.NotificationCategory;
import org.jasig.portlet.notice.NotificationError;
import org.jasig.portlet.notice.NotificationResponse;
import org.jasig.portlet.notice.service.AbstractNotificationService;
import org.jasig.portlet.notice.util.UsernameFinder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

import com.sun.syndication.feed.synd.SyndContent;
import com.sun.syndication.feed.synd.SyndEntry;
import com.sun.syndication.feed.synd.SyndFeed;
import com.sun.syndication.io.SyndFeedInput;
import com.sun.syndication.io.XmlReader;
import org.springframework.beans.factory.annotation.Value;

public final class RomeNotificationService extends AbstractNotificationService {

    private static final String URLS_PREFERENCE = "RomeNotificationService.urls";

    private static final Object NO_FURTHER_INFORMATION = "No further information is available.";
    private static final DateFormat DATE_FORMAT = DateFormat.getDateInstance(DateFormat.SHORT);

    /**
     * Service URLs defined in an external file, in the post-Portlet API style.
     * Comma-separated list.
     */
    @Value("${RomeNotificationService.feedUrls:}")
    private String feedUrlsProperty;

    private List<String> feedUrlsList = Collections.emptyList();

    private Cache cache;

    @Autowired
    private UsernameFinder usernameFinder;

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Resource(name="RomeNotificationService.feedCache")
    public void setCache(Cache cache) {
        this.cache = cache;
    }

    @PostConstruct
    public void init() {
        if (!StringUtils.isEmpty(feedUrlsProperty)) {
            feedUrlsList =
                    Collections.unmodifiableList(Arrays.asList(feedUrlsProperty.split(",")));
        }
    }

    @Override
    public void invoke(final ActionRequest req, final ActionResponse res, final boolean refresh) {

        if (logger.isDebugEnabled()) {
            logger.debug("Performing RomeNotificationService.invoke() for user='"
                                    + usernameFinder.findUsername(req)
                                    + "' refresh=" + refresh);
        }

        if (refresh) {
            final PortletPreferences prefs = req.getPreferences();
            final String[] urls = prefs.getValues(URLS_PREFERENCE, new String[0]);
            for (String item : urls) {
                cache.remove(item);
            }
        }

    }

    @Override
    public NotificationResponse fetch(final PortletRequest req) {

        final PortletPreferences prefs = req.getPreferences();
        final String[] feedUrls = prefs.getValues(URLS_PREFERENCE, new String[0]);
        final String username = usernameFinder.findUsername(req);
        return doFetch(Arrays.asList(feedUrls), username);

    }

    @Override
    public NotificationResponse fetch(HttpServletRequest req) {

        final String username = usernameFinder.findUsername(req);
        return doFetch(feedUrlsList, username);

    }

    /*
     * Implementation
     */

    private NotificationResponse doFetch(List<String> feedUrls, String username) {

        final List<NotificationCategory> categories = new ArrayList<>();
        final List<NotificationError> errors = new ArrayList<>();

        for (String item : feedUrls) {

            // It's okay to pull a response from cache, if we have one, since refresh happens in invoke()
            final Element m = cache.get(item);
            if (m != null) {
                // ## CACHE HIT ##
                if (logger.isDebugEnabled()) {
                    logger.debug("Feed cache HIT for url:  " + item);
                }
                final NotificationCategory category = (NotificationCategory) m.getObjectValue();
                categories.add(category);
            } else {
                // ## CACHE MISS ##
                if (logger.isDebugEnabled()) {
                    logger.debug("Feed cache MISS for url:  " + item);
                    logger.debug("Checking the following feed URL for notifications for user '"
                            + username + "' -- " + item);
                }
                final NotificationCategory category = fetchFromSourceUrl(item);
                if (category != null) {
                    cache.put(new Element(item, category));
                    categories.add(category);
                } else {
                    final NotificationError error = new NotificationError();
                    error.setError("Service Unavailable");
                    error.setSource(getName());
                    errors.add(error);
                }
            }

        }

        final NotificationResponse rslt = new NotificationResponse();
        rslt.setCategories(categories);
        rslt.setErrors(errors);
        return rslt;

    }

    private NotificationCategory fetchFromSourceUrl(final String url) {

        NotificationCategory rslt = null;  // default

        XmlReader reader = null;
        try {

            final URL u = new URL(url);

            reader = new XmlReader(u);
            final SyndFeedInput input = new SyndFeedInput();
            final SyndFeed feed = input.build(reader);

            rslt = new NotificationCategory();
            rslt.setTitle(feed.getTitle());

            final List<TimestampNotificationEntry> entries = new ArrayList<>();

            @SuppressWarnings("unchecked")
            final List<SyndEntry> list = feed.getEntries();
            for (SyndEntry y : list) {

                if (logger.isTraceEnabled()) {
                    logger.trace("Processing SyndEntry:  \n" + y.toString());
                }

                final long timestamp = y.getPublishedDate().getTime();
                final TimestampNotificationEntry entry = new TimestampNotificationEntry(timestamp);

                // Strongly-typed members
                entry.setSource(feed.getAuthor());
                entry.setTitle(y.getTitle());
                entry.setUrl(y.getLink());
                // No priority
                // No due date
                // No image

                // Body
                final StringBuilder body = new StringBuilder();
                final SyndContent desc = y.getDescription();
                if (desc != null) {
                    // Prefer description
                    body.append(desc.getValue());
                }
                if (body.length() == 0) {
                    // Fall back to contents
                    @SuppressWarnings("unchecked")
                    final List<SyndContent> contents = y.getContents();
                    for (SyndContent c : contents) {
                        body.append(c.getValue());
                    }
                }
                if (body.length() == 0) {
                    // Last resort...
                    body.append(NO_FURTHER_INFORMATION);
                }
                entry.setBody(body.toString());

                // Attributes -- TODO:  These labels should be internationalized in messages.properties
                final List<NotificationAttribute> attributes = new ArrayList<>();
                final String author = y.getAuthor();
                if (StringUtils.isNotBlank(author)) {
                    attributes.add(new NotificationAttribute("Author", author));
                }
                final Date publishedDate = y.getPublishedDate();
                if (publishedDate != null) {
                    attributes.add(new NotificationAttribute("Published date", DATE_FORMAT.format(publishedDate)));
                }
                final Date updatededDate = y.getUpdatedDate();
                if (updatededDate != null) {
                    attributes.add(new NotificationAttribute("Updated date", DATE_FORMAT.format(updatededDate)));
                }
                entry.setAttributes(attributes);

                entries.add(entry);

            }

            // Items should be in reverse chronological order
            Collections.sort(entries);
            Collections.reverse(entries);

            rslt.setEntries(new ArrayList<>(entries));

        } catch (Exception e) {
            final String msg = "Unable to read the specified feed:  " + url;
            logger.error(msg, e);
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException ioe) {
                    final String msg = "Unable to close the XmlReader";
                    logger.error(msg, ioe);
                }
            }
        }

        return rslt;

    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service.jpa;

import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;

import org.apache.commons.lang.Validate;
import org.jasig.portlet.notice.NotificationState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

/**
 * This DAO class handles raw CRUD operations for the JPA-flavor notifications.
 *
 * @since 3.0
 * @author drewwills
 */
@Repository
/* package-private */ class JpaNotificationDao implements INotificationDao {

    @PersistenceContext
    private EntityManager entityManager;

    private final Logger log = LoggerFactory.getLogger(getClass());

    /**
     * Search for a JpaEntry with the specified Id. If the entry exists in the
     * persistence context, it is returned;  otherwise, null is returned.
     */
    @Override
    @Transactional(readOnly=true)
    public JpaEntry getEntry(long entryId) {
        Validate.isTrue(entryId > 0, "Invalid entryId:  " + entryId);

        JpaEntry rslt = entityManager.find(JpaEntry.class, entryId);
        return rslt;
    }

    @Transactional(readOnly = true)
    public JpaEntry getFullEntry(long entryId) {
        Validate.isTrue(entryId > 0, "Invalid entryId: " + entryId);

        TypedQuery<JpaEntry> query = entityManager.createNamedQuery("JpaEntry.getFullById", JpaEntry.class);
        query.setParameter("entryId", entryId);
        return query.getSingleResult();
    }

    @Override
    @Transactional(readOnly = true)
    public List<JpaEntry> list(Integer page, Integer pageSize) {
        TypedQuery<JpaEntry> query = entityManager.createNamedQuery("JpaEntry.getAll", JpaEntry.class);

        if (page != null && pageSize != null) {
            query.setFirstResult(page * pageSize);
            query.setMaxResults(pageSize);
        }

        return query.getResultList();
    }

    @Override
    @Transactional(readOnly = true)
    public List<JpaEntry> getNotificationsBySourceAndCustomAttribute(String source, String attributeName, String attributeValue) {
        final String jpql = "SELECT e FROM JpaEntry e "
                + "WHERE e.source = :source "
                + "AND EXISTS ("
                    + "SELECT a FROM JpaAttribute a "
                    + "WHERE a.entry = e "
                    + "AND a.name = :name "
                    + "AND :value MEMBER OF a.values)";
        final TypedQuery<JpaEntry> query = entityManager.createQuery(jpql, JpaEntry.class);
        query.setParameter("source", source);
        query.setParameter("name", attributeName);
        query.setParameter("value", attributeValue);
        return query.getResultList();
    }

    @Override
    @Transactional
    public JpaEntry createOrUpdateEntry(JpaEntry entry) {
        Validate.notNull(entry, "Argument 'entry' cannot be null");

        if (entry.getId() == 0) {
            // need to save and then flush to ensure the auto-generated
            // key value is populated in the entity.
            entityManager.persist(entry);
            entityManager.flush();
        } else {
            // should always work with the object returned from merge
            // rather than the original.
            entry = entityManager.merge(entry);
        }

        return entry;
    }

    @Override
    @Transactional
    public void removeEntry(JpaEntry entry) {
        Validate.notNull(entry, "Argument 'entry' cannot be null");

        JpaEntry y = entityManager.merge(entry);  // Insures that cascades will be handled properly
        entityManager.remove(y);
    }

    @Override
    @Transactional(readOnly=true)
    public Set<JpaEntry> getEntriesByRecipient(String username) {
        Validate.notEmpty(username, "Argument 'username' cannot be empty");

        final String jpql = "SELECT v.entry FROM JpaEvent v "
                                    + "WHERE v.username = :username "
                                    + "AND v.entry.priority != 0"; // See below...

        /*
         * Note on Priority 0 entries: the Notification project sometimes uses 'proxy' entries
         * within the JPA service to track state changes.  These proxies always have a priority of
         * zero, and should not appear in a user's visable list of notifications.
         */

        TypedQuery<JpaEntry> query = entityManager.createQuery(jpql, JpaEntry.class);

        log.debug("Query getEntriesByRecipient={}", query.toString());

        query.setParameter("username", username);
        List<JpaEntry> rslt = query.getResultList();
        return new HashSet<>(rslt);
    }

    @Override
    @Transactional(readOnly=true)
    public Set<JpaEntry> getEntriesByRecipientByStatus(String username,
            Set<NotificationState> include, Set<NotificationState> exclude) {
        throw new UnsupportedOperationException();
    }

    @Override
    @Transactional(readOnly = true)
    public List<JpaEvent> getEvents(long entryId) {
        Validate.isTrue(entryId > 0, "Argument 'entryId' must be greater than zero (0)");

        TypedQuery<JpaEvent> query = entityManager.createNamedQuery("JpaEvent.getAllByEntryId", JpaEvent.class);
        query.setParameter("entryId", entryId);

        List<JpaEvent> events = query.getResultList();
        return events;
    }

    @Override
    @Transactional(readOnly=true)
    public List<JpaEvent> getEvents(long entryId, String username) {
        Validate.isTrue(entryId > 0, "Argument 'entryId' must be greater than zero (0)");
        Validate.notEmpty(username, "Argument 'username' cannot be empty");

        final String jpql = "SELECT v FROM JpaEvent v WHERE v.username = :username "
                + "AND v.entry = (SELECT e FROM JpaEntry e WHERE e.id = :entryId) "
                + "ORDER BY v.timestamp";
        TypedQuery<JpaEvent> query = entityManager.createQuery(jpql, JpaEvent.class);
        query.setParameter("username", username);
        query.setParameter("entryId", entryId);
        List<JpaEvent> rslt = query.getResultList();
        return rslt;
    }

    @Override
    @Transactional
    public JpaAddressee createOrUpdateAddressee(JpaAddressee addressee) {
        Validate.notNull(addressee, "Argument 'addressee' cannot be null");

        if (addressee.getId() == 0) {
            entityManager.persist(addressee);
            entityManager.flush();
        } else {
            addressee = entityManager.merge(addressee);
        }

        return addressee;
    }

    @Override
    @Transactional(readOnly = true)
    public JpaAddressee getAddressee(long addresseeId) {
        Validate.isTrue(addresseeId > 0, "'addresseeId' must be greater than 0");

        JpaAddressee addr = entityManager.find(JpaAddressee.class, addresseeId);
        return addr;
    }

    @Override
    @Transactional
    public JpaEvent createOrUpdateEvent(JpaEvent event) {
        Validate.notNull(event, "Argument 'event' cannot be null");

        if (event.getId() == 0) {
            entityManager.persist(event);
            entityManager.flush();
        } else {
            event = entityManager.merge(event);
        }
        return event;
    }

    @Override
    @Transactional(readOnly = true)
    public JpaEvent getEvent(long eventId) {
        JpaEvent event = entityManager.find(JpaEvent.class, eventId);
        return event;
    }
}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service.rest;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import org.apache.commons.lang3.StringUtils;
import org.apereo.portal.soffit.Headers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;

import javax.annotation.Resource;
import javax.portlet.PortletRequest;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;

/**
 * Concrete implementation of {@link IParameterEvaluator} based on user attributes.  Works with
 * both PortletRequest (though deprecated) and HttpServletRequest.
 *
 * @since 3.1
 */
public class UserAttributeParameterEvaluator extends AbstractParameterEvaluator {

    @Resource(name="soffitSignatureKey")
    private String signatureKey;

    private String userAttributeKey;
    private String claimName;

    private final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * Defines the name of the user attribute that will be evaluated using the Portlet API.
     *
     * @param userAttributeKey The name of a user attribute, e.g. "user.login.id"
     * @deprecated Prefer interactions that are not based on the Portlet API
     */
    @Deprecated
    public void setUserAttributeKey(String userAttributeKey) {
        this.userAttributeKey = userAttributeKey;
    }

    /**
     * Defines the name of the user attribute that will be evaluated using the Servlet API and Open
     * ID Connect (OIDC).
     *
     * @param claimName The name of a claim in the OIDC Id token
     */
    public void setClaimName(String claimName) {
        this.claimName = claimName;
    }

    public String evaluate(PortletRequest request) {
        @SuppressWarnings("unchecked")
        Map<String,String> userInfo = (Map<String,String>) request.getAttribute(PortletRequest.USER_INFO);
        return userInfo.get(this.userAttributeKey);
    }

    @Override
    public String evaluate(HttpServletRequest req) {

        String rslt = null;  // default

        final Jws<Claims> oidcToken = parseOidcToken(req);
        if (oidcToken != null) {
            final Object claimValue = oidcToken.getBody().get(claimName);
            if (claimValue != null) {
                rslt = claimValue.toString();
            }
        }

        return rslt;

    }

    /*
     * Implementation
     */

    /**
     * Obtains information about the user from the Authorization header in the form of an OIDC Id
     * token.  In the future, it would be better to provide a standard tool (bean) for this job in
     * the uPortal-soffit-renderer component.
     */
    private Jws<Claims> parseOidcToken(HttpServletRequest req) {

        final String authHeader = req.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.isBlank(authHeader)
                || !authHeader.startsWith(Headers.BEARER_TOKEN_PREFIX)) {
            /*
             * No OIDC token available
             */
            return null;
        }

        final String bearerToken = authHeader.substring(Headers.BEARER_TOKEN_PREFIX.length());

        try {
            // Validate & parse the JWT
            final Jws<Claims> rslt =
                    Jwts.parser().setSigningKey(signatureKey).parseClaimsJws(bearerToken);

            logger.debug("Found the following OIDC Id token:  {}", rslt.toString());

            return rslt;
        } catch (Exception e) {
            logger.info("The following Bearer token is unusable:  '{}'", bearerToken);
            logger.debug("Failed to validate and/or parse the specified Bearer token", e);
        }

        return null;

    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service.jdbc;

import java.io.IOException;

import javax.annotation.PostConstruct;
import javax.portlet.PortletRequest;
import javax.servlet.http.HttpServletRequest;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.jasig.portlet.notice.NotificationResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.jdbc.core.ResultSetExtractor;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;

/**
 * Concrete implementation of {@link AbstractJdbcNotificationService} that returns a 100% static
 * {@link NotificationResponse} from the classpath if the configured SQL returns at least one row.
 * Otherwise it returns NotificationResponse.EMTY_RESPONSE.  The default
 * {@link SqlParameterSource} wraps the PortletRequest.USER_INFO collection (Portlet
 * API) or the OIDC Id Token (Servlet API), but subclasses may override this strategy.
 *
 * @since 3.2
 */
public class AnyRowResourceContentJdbcNotificationService extends AbstractJdbcNotificationService {

    // Managed by Spring
    private Resource jsonResource;

    // Managed internally
    private ObjectMapper objectMapper = new ObjectMapper();
    private NotificationResponse nonEmptyResponse;
    private final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * Specify the complete content of the {@link NotificationResponse}.
     */
    public void setJsonResource(Resource jsonResource) {
        this.jsonResource = jsonResource;
    }

    /**
     * Subclasses must call super.init().
     */
    @PostConstruct
    public void init() {
        super.init(); // Very important!
        try {
            nonEmptyResponse = objectMapper.readValue(jsonResource.getURL(), NotificationResponse.class);
        } catch (IOException ioe) {
            final String msg = "Failed to load JSON from resource:  " + jsonResource;
            throw new RuntimeException(msg, ioe);
        }
    }

    @Override
    protected ResultSetExtractor<NotificationResponse> getResultSetExtractor(PortletRequest req) {
        return getResultSetExtractor();
    }

    @Override
    protected ResultSetExtractor<NotificationResponse> getResultSetExtractor(HttpServletRequest request) {
        return getResultSetExtractor();
    }

    private ResultSetExtractor<NotificationResponse> getResultSetExtractor() {
        return rs -> {
            if (rs.next()) {
                // The user should get the configured notification
                logger.debug("ResultSet NOT empty for notification service '{}'", getName());
                return nonEmptyResponse;
            } else {
                // The user should NOT get the configured notification
                logger.debug("ResultSet empty for notification service '{}'", getName());
                return NotificationResponse.EMPTY_RESPONSE;
            }
        };
    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service.ssp;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang.Validate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

import java.net.MalformedURLException;
import java.net.URL;

/**
 * Helper service to simplify interacting with the SSP REST Api.
 * Class is will take care of authentication and holds information
 * about the SSP host and context.
 *
 * @author Josh Helmer, jhelmer.unicon.net
 */
public class SSPApi implements ISSPApi {
    private static final String AUTHORIZATION = "authorization";
    private static final String BASIC = "Basic";
    private static final String GRANT_TYPE = "grant_type";
    private static final String CLIENT_CREDENTIALS = "client_credentials";

    private RestTemplate restTemplate;
    private String clientId;
    private String clientSecret;
    private String authenticationUrl = "/api/1/oauth2/token";
    private String sspProtocol = "https";
    private String sspHost = "locahost";
    private int sspPort = 443;
    private String sspContext = "/ssp";

    /**
     * Track the portals authentication token.  Note that this should NOT be
     * accessed directly outside of getAuthenticationToken
     */
    private SSPToken authenticationToken;

    @Autowired
    public void setRestTemplate(final RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @Value("${studentSuccessPlanService.clientId:}")
    public void setClientId(String clientId) {
        this.clientId = clientId;
    }

    @Value("${studentSuccessPlanService.clientSecret:}")
    public void setClientSecret(String clientSecret) {
        this.clientSecret = clientSecret;
    }

    @Value("${studentSuccessPlanService.authenticationUrl:/api/1/oauth2/token}")
    public void setAuthenticationUrl(String authenticationUrl) {
        this.authenticationUrl = authenticationUrl;
    }

    @Value("${studentSuccessPlanService.sspProtocol:https}")
    public void setSspProtocol(String sspProtocol) {
        Validate.isTrue(sspProtocol.equalsIgnoreCase("http") || sspProtocol.equalsIgnoreCase("https"),
                "sspProtocol must be set to either 'http' or 'https'"
        );

        this.sspProtocol = sspProtocol;
    }

    @Value("${studentSuccessPlanService.sspHost:}")
    public void setSspHost(String sspHost) {
        this.sspHost = sspHost;
    }

    @Value("${studentSuccessPlanService.sspPort:443}")
    public void setSspPort(int sspPort) {
        this.sspPort = sspPort;
    }

    /**
     * Set the SSP API Context.   Note:  this method will ensure that that the API context
     * starts with '/' and ensures that it does not end with a '/'.
     *
     * @param sspContext the API context
     */
    @Value("${studentSuccessPlanService.sspContext:/ssp}")
    public void setSspContext(String sspContext) {
        // ensure leading '/'
        if (!sspContext.startsWith("/")) {
            sspContext = "/" + sspContext;
        }

        // remove any trailing '/'
        if (sspContext.endsWith("/")) {
            sspContext = sspContext.replaceAll("/*$", "");
        }

        this.sspContext = sspContext;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public <T> ResponseEntity<T> doRequest(SSPApiRequest<T> request) throws MalformedURLException, RestClientException {
        SSPToken token = getAuthenticationToken(false);

        request.setHeader(AUTHORIZATION, token.getTokenType() + " " + token.getAccessToken());

        URL url = getSSPUrl(request.getUrlFragment(), true);
        ResponseEntity<T> response = restTemplate.exchange(url.toExternalForm(),
                request.getMethod(),
                request.getRequestEntity(),
                request.getResponseClass(),
                request.getUriParameters());
        // if we get a 401, the token may have unexpectedly expired (eg. ssp server restart).
        // Clear it, get a new token and replay the request one time.
        if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
            token = getAuthenticationToken(true);
            request.setHeader(AUTHORIZATION, token.getTokenType() + " " + token.getAccessToken());
            return restTemplate.exchange(url.toExternalForm(),
                    request.getMethod(),
                    request.getRequestEntity(),
                    request.getResponseClass(),
                    request.getUriParameters());
        }

        return response;
    }

    /**
     * {@inheritDoc}
     */
    public URL getSSPUrl(String urlFragment, boolean useContext) throws MalformedURLException {
        String path = (useContext) ? sspContext + urlFragment : urlFragment;
        return new URL(sspProtocol, sspHost, sspPort, path);
    }

    /**
     * Get the authentication token to use.
     *
     * @param forceUpdate if true, get a new auth token even if a cached instance exists.
     * @return The authentication token
     * @throws MalformedURLException if the authentication URL is invalid
     * @throws RestClientException if an error occurs when talking to SSP
     */
    private synchronized SSPToken getAuthenticationToken(boolean forceUpdate) throws MalformedURLException, RestClientException {
        if (authenticationToken != null && !authenticationToken.hasExpired() && !forceUpdate) {
            return authenticationToken;
        }

        String authString = getClientId() + ":" + getClientSecret();
        String authentication = new Base64().encodeToString(authString.getBytes());

        HttpHeaders headers = new HttpHeaders();
        headers.add(AUTHORIZATION, BASIC + " " + authentication);

        // form encode the grant_type...
        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
        form.add(GRANT_TYPE, CLIENT_CREDENTIALS);

        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(form, headers);

        URL authURL = getAuthenticationURL();
        authenticationToken = restTemplate.postForObject(authURL.toExternalForm(), request, SSPToken.class);
        return authenticationToken;
    }

    private String getClientId() {
        return clientId;
    }

    private String getClientSecret() {
        return clientSecret;
    }

    private URL getAuthenticationURL() throws MalformedURLException {
        return getSSPUrl(authenticationUrl, true);
    }
}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice;

import static java.util.stream.Collectors.partitioningBy;
import static java.util.stream.Collectors.toMap;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlTransient;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class contains all the categories and errors retrieved by an INotificationService. It is
 * also used to aggregate all the NotificationResponses from various services into a single
 * NotificationResponse.  The data from the overall NotificationResponse instance is returned to the
 * portlet to be rendered.
 */
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@XmlAccessorType(XmlAccessType.FIELD)
public class NotificationResponse implements Serializable, Cloneable {

    private static final long serialVersionUID = 1L;

    /**
     * Useful for returning an empty set of notices.
     */
    public static final NotificationResponse EMPTY_RESPONSE = new NotificationResponse() {
        @Override
        public void setCategories(List<NotificationCategory> categories) {
            throw new UnsupportedOperationException("NotificationResponse.EMPTY_RESPONSE is and must be immutable");
        }
        @Override
        public void setErrors(List<NotificationError> errors) {
            throw new UnsupportedOperationException("NotificationResponse.EMPTY_RESPONSE is and must be immutable");
        }
    };

    @XmlTransient
    private final Logger log = LoggerFactory.getLogger(getClass());

    private List<NotificationCategory> categories = new ArrayList<>();
    private List<NotificationError> errors = new ArrayList<>();

    /**
     * Field indicating the response is a cloned defensive copy and can be
     * modified (specifically,you can replace the collections with new
     * collections)
     */
    @JsonIgnore
    @XmlTransient
    private boolean cloned = false;

    public NotificationResponse() {}

    public NotificationResponse(NotificationResponse response) {
        this(response.getCategories(), response.getErrors());
    }

    public NotificationResponse(List<NotificationCategory> categories, List<NotificationError> errors) {
        this.setCategories(categories);
        this.setErrors(errors);
    }

    public List<NotificationCategory> getCategories() {
        return Collections.unmodifiableList(categories);
    }

    public void setCategories(List<NotificationCategory> categories) {
        this.categories.clear();
        if (categories != null) {
            this.categories.addAll(
                    categories.stream()
                            .map(NotificationCategory::cloneNoExceptions)
                            .collect(Collectors.toList())
            );
        }
    }

    public List<NotificationError> getErrors() {
        return Collections.unmodifiableList(errors);
    }

    public void setErrors(List<NotificationError> errors) {
        this.errors.clear();
        addErrors(errors);
    }

    /**
     * Returns the {@link NotificationEntry} with the specified id, or null if
     * not present.
     */
    public NotificationEntry findNotificationEntryById(final String notificationId) {

        // Assertions
        if (notificationId == null) {
            String msg = "Argument 'notificationId' cannot be null";
            throw new IllegalArgumentException(msg);
        }

        // Providing a brute-force implementation for
        // now;  we can improve it if it becomes important.
        NotificationEntry rslt = null;  // default -- means not present
        for (NotificationCategory category : categories) {
            for (NotificationEntry entry : category.getEntries()) {
                if (notificationId.equals(entry.getId())) {
                    rslt = entry;
                    break;
                }
            }
            if (rslt != null) {
                break;
            }
        }

        return rslt;

    }

    /**
     * Combine the contents of this response with the provided response and
     * return a new instance of {@link NotificationResponse}.  The
     * original instances are unchanged.
     *
     * @param response A new {@link NotificationResponse} that contains data from both originals
     */
    public NotificationResponse combine(NotificationResponse response) {
        NotificationResponse rslt = new NotificationResponse(this);
        rslt.addCategories(response.getCategories());
        rslt.addErrors(response.getErrors());
        return rslt;
    }

    /**
     * Return a new instance of {@link NotificationResponse} from which
     * the specified errors have been removed.  The original instances are
     * unchanged.
     *
     * @param hiddenErrorKeys set of error keys to exclude from result
     */
    public NotificationResponse filterErrors(Set<Integer> hiddenErrorKeys) {
        NotificationResponse rslt = new NotificationResponse(this);
        List<NotificationError> filteredErrors = new ArrayList<>();
        for (NotificationError r : errors) {
            if (!hiddenErrorKeys.contains(r.getKey())) {
                filteredErrors.add(r);
            }
        }
        rslt.setErrors(filteredErrors); // deep copy
        return rslt;
    }

    /**
     * Return a new instance of {@link NotificationResponse} containing only
     * {@link NotificationEntry} objects within this response that match the specified
     * Predicate.  The category structure is preserved.  Empty categories are removed.
     */
    public NotificationResponse filter(Predicate<NotificationEntry> predicate) {

        final List<NotificationCategory> filteredCategories = categories.stream()
                .map(category -> {
                    final List<NotificationEntry> filteredEntries = category.getEntries().stream()
                            .filter(predicate)
                            .collect(Collectors.toList());
                    return filteredEntries.size() > 0
                            ? new NotificationCategory(category.getTitle(), filteredEntries)
                            : null;
                })
                .filter(value -> value != null)
                .collect(Collectors.toList());
        return new NotificationResponse(filteredCategories, getErrors()); // deep copy

    }

    /**
     * Provides the total number of notifications contained in the response.
     */
    @JsonIgnore
    @XmlTransient
    public int size() {
        return categories.stream()
                .map(NotificationCategory::getEntries)
                .mapToInt(List::size)
                .sum();
    }

    @Override
    public String toString() {
        StringBuilder builder = new StringBuilder();
        builder.append("NotificationResponse [categories=");
        builder.append(categories);
        builder.append(", errors=");
        builder.append(errors);
        builder.append("]");
        return builder.toString();
    }

    /**
     * Implements deep-copy clone.
     *
     * @throws CloneNotSupportedException Not really, but it's on the method
     * signature we're overriding.
     */
    @Override
    protected Object clone() throws CloneNotSupportedException {

        // Start with superclass impl (handles immutables and primitives)
        final NotificationResponse rslt = (NotificationResponse) super.clone();

        // Adjust to satisfy deep-copy strategy
        List<NotificationCategory> cList = new ArrayList<>(categories.size());
        for (NotificationCategory category : categories) {
            cList.add((NotificationCategory) category.clone());
        }
        rslt.setCategories(cList);
        List<NotificationError> eList = new ArrayList<>(errors.size());
        for (NotificationError error : errors) {
            eList.add((NotificationError) error.clone());
        }
        rslt.setErrors(eList);
        rslt.setCloned(true);

        return rslt;

    }

    /*
     * Implementation
     */

    /**
     * Insert the given categories and their entries into the any existing
     * categories of the same title. If a category doesn't match an existing
     * one, add it to the list.
     * @param newCategories collection of new categories and their entries.
     */
    private void addCategories(List<NotificationCategory> newCategories) {
        if (newCategories == null) {
            return;
        }

        // Start with a deep copy of the method parameter to simplify remaining logic
        newCategories = newCategories.parallelStream().map(NotificationCategory::cloneNoExceptions).collect(Collectors.toList());

        // Create a map of current categories by title for processing
        Map<String, NotificationCategory> catsByName =
                this.categories.parallelStream().collect(toMap(c -> c.getTitle().toLowerCase(), c -> c));

        // Split new categories between those that match an existing category and those that are completely new
        Map<Boolean, List<NotificationCategory>> matchingNewCats =
                newCategories.stream()
                        .collect(partitioningBy(c -> catsByName.containsKey(c.getTitle().toLowerCase())));

        // Add new entries to existing categories
        matchingNewCats.get(Boolean.TRUE).stream()
                .forEachOrdered(c -> catsByName.get(c.getTitle().toLowerCase()).addEntries(c.getEntries()));

        // Add new categories
        this.categories.addAll(matchingNewCats.get(Boolean.FALSE));
    }

    private void addErrors(List<NotificationError> newErrors) {
        if (newErrors != null) {
            this.errors.addAll(
                    newErrors.stream()
                            .map(NotificationError::cloneNoExceptions)
                            .collect(Collectors.toList())
            );
        }
    }

    /**
     * Best guess as to the reason this method exists: beans that produce fresh
     * {@link NotificationResponse} objects (without cloning them) may safely cache them.  They need
     * not fear that another bean will transform their copy.  Beans that do clone them, however, can
     * safely operate on a clone.
     */
    public NotificationResponse cloneIfNotCloned() {
        try {
            return isCloned() ? this : (NotificationResponse) this.clone();
        } catch (CloneNotSupportedException e) {
            log.error("Failed to clone() the sourceResponse", e);
        }
        return this;
    }

    private boolean isCloned() {
        return cloned;
    }

    private void setCloned(boolean cloned) {
        this.cloned = cloned;
    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service;

import java.util.Arrays;

import javax.portlet.ActionRequest;
import javax.portlet.ActionResponse;
import javax.portlet.EventRequest;
import javax.portlet.EventResponse;
import javax.portlet.PortletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.jasig.portlet.notice.INotificationService;
import org.jasig.portlet.notice.NotificationError;
import org.jasig.portlet.notice.NotificationResponse;
import org.jasig.portlet.notice.util.UsernameFinder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Required;

public abstract class AbstractNotificationService implements INotificationService {

    private String name;

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public String getName() {
        return name;
    }

    @Required
    public void setName(String name) {
        this.name = name;
    }

    /**
     * Subclasses of {@link AbstractNotificationService} that need to perform
     * some logic within this method should override it.
     */
    @Override
    public void invoke(final ActionRequest req, final ActionResponse res, final boolean refresh) { /* no-op */ }

    /**
     * Subclasses of {@link AbstractNotificationService} that need to perform
     * some logic within this method should override it.
     */
    @Override
    public void collect(final EventRequest req, final EventResponse res) { /* no-op */ }

    /**
     * Returns true.  Subclasses of {@link AbstractNotificationService}
     * that can better answer this question should override this method.
     */
    @Override
    public boolean isValid(final PortletRequest req, final NotificationResponse previousResponse) {
        return true;
    }

    /**
     * Returns an empty collection and logs the event.  All concrete implementations of
     * {@link INotificationService} should override this method.  The log entries this method
     * produces are an indication that the service subclass is not ready for the post-Portlet API
     * world.
     */
    @Override
    public NotificationResponse fetch(HttpServletRequest request) {
        logger.warn("Notification service '{}' was invoked by the portlet-agnostic API, but it " +
                "doesn't override fetch(HttpServletRequest)", getName());
        return NotificationResponse.EMPTY_RESPONSE;
    }

    /*
     * Implementation
     */

    @Autowired
    protected UsernameFinder usernameFinder;

    protected final String createServiceUserWindowSpecificCacheKey(PortletRequest req) {
        /*
         * Cache keys are specific to (all of the below)...
         *
         *   - Service Name
         *   - Username
         *   - Portlet WindowId
         */
        final StringBuilder rslt = new StringBuilder();
        rslt.append(getName()).append("|").append(usernameFinder.findUsername(req))
                                        .append("|").append(req.getWindowID());
        return rslt.toString();
    }

    protected final NotificationResponse prepareErrorResponse(final String source, final String message) {
        final NotificationError error = new NotificationError();
        error.setSource(source);
        error.setError(message);
        final NotificationResponse rslt =  new NotificationResponse();
        rslt.setErrors(Arrays.asList(new NotificationError[] { error }));
        return rslt;
    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service.ssp;

import com.jayway.jsonpath.Configuration;
import com.jayway.jsonpath.JsonPath;
import com.jayway.jsonpath.Option;
import com.jayway.jsonpath.ReadContext;
import net.minidev.json.JSONArray;
import org.apache.commons.lang.StringUtils;
import org.jasig.portlet.notice.NotificationAction;
import org.jasig.portlet.notice.NotificationCategory;
import org.jasig.portlet.notice.NotificationEntry;
import org.jasig.portlet.notice.NotificationError;
import org.jasig.portlet.notice.NotificationResponse;
import org.jasig.portlet.notice.NotificationState;
import org.jasig.portlet.notice.service.AbstractNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import javax.portlet.PortletPreferences;
import javax.portlet.PortletRequest;
import java.net.MalformedURLException;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static java.lang.String.format;

/**
 * Read the list of user task from SSP.
 *
 * @author Josh Helmer, jhelmer.unicon.net
 */
public class SSPTaskNotificationService extends AbstractNotificationService {
    private final Logger log = LoggerFactory.getLogger(getClass());

    private static final String NOTIFICATION_CATEGORY_PREF = "SSPTaskNotificationService.categoryName";
    private static final String SSP_NOTIFICATIONS_ENABLED = "SSPTaskNotificationService.enabled";
    private static final String SSP_NOTIFICATIONS_ENABLE_MARK_COMPLETED = "SSPTaskNotificationService.enableMarkCompletedAction";
    private static final String DEFAULT_CATEGORY = "Student Success Plan";
    private static final String NOTIFICATION_SOURCE_PREF = "SSPTaskNotificationService.sourceName";
    private static final String DEFAULT_NOTIFICATION_SOURCE = "Student Success Plan";
    private static final String SUCCESS_QUERY = "$.success";
    private static final String MESSAGE_QUERY = "$.message";
    private static final String ROWS_QUERY = "$.rows";
    private static final String ROW_ID_QUERY_FMT = "$.rows[%d].id";
    private static final String ROW_NAME_QUERY_FMT = "$.rows[%d].name";
    private static final String ROW_DESCRIPTION_QUERY_FMT = "$.rows[%d].description";
    private static final String ROW_DUE_DATE_QUERY_FMT = "$.rows[%d].dueDate";
    private static final String ROW_COMPLETED_QUERY_FMT = "$.rows[%d].completed";
    private static final String ROW_LINK_QUERY_FMT = "$.rows[%d].link";

    private ISSPApi sspApi;
    private ISSPPersonLookup personLookup;
    private String activeTaskURLFragment = "/api/1/person/{personId}/task?STATUS=ACTIVE&limit=1000";
    private SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");

    @Autowired
    public void setSspApi(ISSPApi sspApi) {
        this.sspApi = sspApi;
    }

    @Autowired
    public void setPersonLookup(ISSPPersonLookup personLookup) {
        this.personLookup = personLookup;
    }

    /**
     * Fetch the set of SSP tasks for the uPortal user.
     *
     * @param req The PortletRequest
     * @return The set of notifications for this data source.
     */
    @Override
    public NotificationResponse fetch(PortletRequest req) {
        PortletPreferences preferences = req.getPreferences();
        String enabled = preferences.getValue(SSP_NOTIFICATIONS_ENABLED, "false");
        if (!"true".equalsIgnoreCase(enabled)) {
            return new NotificationResponse();
        }

        String personId = getPersonId(req);
        if (personId == null) {
            // Not all students will have active SSP records,
            // so if no entry is found in SSP, just return an
            // empty response set.
            return new NotificationResponse();
        }

        String urlFragment = getActiveTaskUrl();
        SSPApiRequest<String> request = new SSPApiRequest<>(urlFragment, String.class)
                .addUriParameter("personId", personId);

        ResponseEntity<String> response;
        try {
            response = sspApi.doRequest(request);
        } catch (Exception e) {
            log.error("Error reading SSP Notifications: " + e.getMessage());
            return notificationError(e.getMessage());
        }

        if (response.getStatusCode().series() != HttpStatus.Series.SUCCESSFUL) {
            log.error("Error reading SSP Notifications: " + response);
            return notificationError(response.getBody());
        }

        NotificationResponse notification = mapToNotificationResponse(req, response);
        return notification;
    }

    /**
     * Error handler.
     *
     * @param errorMsg The error message
     * @return a notification response with the error message
     */
    private NotificationResponse notificationError(String errorMsg) {
        NotificationError error = new NotificationError();
        error.setError(errorMsg);
        error.setSource(getClass().getSimpleName());

        NotificationResponse notification = new NotificationResponse();
        notification.setErrors(Arrays.asList(error));
        return notification;
    }

    /**
     * Map and SSP Response to a NotificationResponse.
     *
     * @param request the portlet request
     * @param response the response from the REST call to SSP
     * @return the mapped notification response
     */
    private NotificationResponse mapToNotificationResponse(PortletRequest request, ResponseEntity<String> response) {
        Configuration config = Configuration.builder().options(
                Option.DEFAULT_PATH_LEAF_TO_NULL
        ).build();
        ReadContext readContext = JsonPath
                .using(config)
                .parse(response.getBody());

        // check the status embedded in the response too...
        String success = readContext.read(SUCCESS_QUERY);
        // grr. SSP returns this as a string...
        if (!"true".equalsIgnoreCase(success)) {
            String error = readContext.read(MESSAGE_QUERY);
            return notificationError(error);
        }

        // read the actual tasks...
        Object rows = readContext.read(ROWS_QUERY);
        if (!(rows instanceof JSONArray)) {
            throw new RuntimeException("Expected 'rows' to be an array of tasks");
        }

        String source = getNotificationSource(request);

        List<NotificationEntry> list = new ArrayList<>();
        for (int i = 0; i < ((JSONArray)rows).size(); i++) {
            NotificationEntry entry = mapNotificationEntry(readContext, i, source);
            if (entry != null) {
                attachActions(request, entry);
                list.add(entry);
            }
        }

        // build the notification response...
        NotificationResponse notification = new NotificationResponse();
        if (!list.isEmpty()) {
            NotificationCategory category = getNotificationCategory(request);
            category.addEntries(list);

            notification.setCategories(Arrays.asList(category));
        }

        return notification;
    }

    /**
     * Map a single notification entry.
     *
     * @param readContext the parsed JSON from SSP
     * @param index the index of the current entry to read
     * @param source the source value to use for the entry
     * @return a new Notification Entry.  May return null if the entry is invalid or complete
     */
    private NotificationEntry mapNotificationEntry(ReadContext readContext, int index, String source) {
        boolean completed = readContext.read(format(ROW_COMPLETED_QUERY_FMT, index), Boolean.class);
        if (completed) {
            return null;
        }

        NotificationEntry entry = new NotificationEntry();
        entry.setSource(source);

        String id = readContext.read(format(ROW_ID_QUERY_FMT, index));
        entry.setId(id);

        String title = readContext.read(format(ROW_NAME_QUERY_FMT, index));
        entry.setTitle(title);

        String desc = readContext.read(format(ROW_DESCRIPTION_QUERY_FMT, index));
        entry.setBody(desc);

        String link = readContext.read(format(ROW_LINK_QUERY_FMT, index));
        URL fixedLink = normalizeLink(link);
        if (fixedLink != null) {
            entry.setUrl(fixedLink.toExternalForm());
        }

        Date createDate = readContext.read(format("$.rows[%d].createdDate", index), Date.class);
        Map<NotificationState, Date> states = new HashMap<>();
        states.put(NotificationState.ISSUED, createDate);

        try {
            // the date is in an odd format, need to parse by hand...
            String dateStr = readContext.read(format(ROW_DUE_DATE_QUERY_FMT, index));
            if (!StringUtils.isBlank(dateStr)) {
                synchronized (dateFormat) {
                    Date dueDate = dateFormat.parse(dateStr);
                    entry.setDueDate(dueDate);
                }
            }
        } catch (Exception e) {
            log.warn("Error parsing due date.  Ignoring", e);
        }

        return entry;
    }

    /**
     * Attach any SSP specific actions to this entry, if enabled.
     *
     * @param request the portlet request
     * @param entry the entry
     */
    private void attachActions(PortletRequest request, NotificationEntry entry) {
        PortletPreferences prefs = request.getPreferences();
        String stringVal = prefs.getValue(SSP_NOTIFICATIONS_ENABLE_MARK_COMPLETED, "false");
        boolean enableMarkCompleted = ("true".equalsIgnoreCase(stringVal));

        List<NotificationAction> actions = new ArrayList<>();

        if (enableMarkCompleted) {
            MarkTaskCompletedAction action = new MarkTaskCompletedAction(entry.getId());
            actions.add(action);
        }

        entry.setAvailableActions(actions);
    }

    /**
     * Some of the links I have seen from SSP are not well formed.   Try to convert any URLs
     * to a usable form.
     *
     * @param link The link value from SSP
     * @return A full URL
     * @throws java.net.MalformedURLException if the link value can not be converted
     *      to a URL.
     */
    private URL normalizeLink(String link) {
        try {
            if (StringUtils.isEmpty(link)) {
                return null;
            }

            if (link.startsWith("/")) {
                return sspApi.getSSPUrl(link, true);
            }

            if (link.startsWith("http://") || link.startsWith("https://")) {
                return new URL(link);
            }

            // if all else fails, just tack on http:// and see if the URL parser can handle
            // it.  Perhaps, not ideal...
            return new URL("http://" + link);
        } catch (MalformedURLException e) {
            log.warn("Bad URL from SSP Entry: " + link, e);
            return null;
        }
    }

    /**
     * Get the category name to use for SSP notifications.
     *
     * @param request the portlet request
     * @return The notification category to use
     */
    private NotificationCategory getNotificationCategory(PortletRequest request) {
        PortletPreferences preferences = request.getPreferences();
        String title = preferences.getValue(NOTIFICATION_CATEGORY_PREF, DEFAULT_CATEGORY);

        NotificationCategory category = new NotificationCategory();
        category.setTitle(title);

        return category;
    }

    private String getActiveTaskUrl() {
        return activeTaskURLFragment;
    }

    private String getPersonId(PortletRequest req) {
        return personLookup.lookupPersonId(req);
    }

    /**
     * Get the source value to use for a Notification entry.
     *
     * @param req the portlet request
     * @return the source value to use.
     */
    private String getNotificationSource(PortletRequest req) {
        PortletPreferences preferences = req.getPreferences();
        String source = preferences.getValue(NOTIFICATION_SOURCE_PREF, DEFAULT_NOTIFICATION_SOURCE);

        return source;
    }
}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.controller;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.annotation.Resource;
import javax.portlet.ActionRequest;
import javax.portlet.ActionResponse;
import javax.portlet.EventRequest;
import javax.portlet.EventResponse;
import javax.portlet.PortletPreferences;
import javax.portlet.ResourceRequest;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jasig.portlet.notice.INotificationService;
import org.jasig.portlet.notice.NotificationAction;
import org.jasig.portlet.notice.NotificationAttribute;
import org.jasig.portlet.notice.NotificationCategory;
import org.jasig.portlet.notice.NotificationConstants;
import org.jasig.portlet.notice.NotificationEntry;
import org.jasig.portlet.notice.NotificationResponse;
import org.jasig.portlet.notice.NotificationResult;
import org.jasig.portlet.notice.util.NotificationResponseFlattener;
import org.jasig.portlet.notice.util.UsernameFinder;
import org.jasig.portlet.notice.util.sort.Sorting;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.portlet.ModelAndView;
import org.springframework.web.portlet.bind.annotation.ActionMapping;
import org.springframework.web.portlet.bind.annotation.EventMapping;
import org.springframework.web.portlet.bind.annotation.ResourceMapping;

/**
 * Gathering of notifications requires action and sometimes event phases.  This
 * controller serves that purpose.
 */
@RequestMapping("VIEW")
public class NotificationLifecycleController {

    public static final String DO_EVENTS_PREFERENCE = "NotificationLifecycleController.doEvents";

    /**
     * Some deployments are experiencing issues whe the 'invokeNotificationService' handler sends a redirect because
     * they're using SSL implemented by a load balancer and the portal/portlet container doesn't realize the traffic is
     * over HTTPS.  In these cases, the ActionURL results in an HTTP 302 redirect with a Location header that starts
     * with 'http://...' and (consequently) the JS in the browser fails, warning of an XSS violation.
     */
    public static final String INVOKE_REDIRECT_PROTOCOL_PREFERENCE = "NotificationLifecycleController.invokeRedirectProtocol";

    /**
     * See INVOKE_REDIRECT_PROTOCOL_PREFERENCE.  If you need to specify an absolute protocol, you may need to specify
     * an absolute port as well.  A blank value for this preference means use the default port for the specified
     * protocol.
     */
    public static final String INVOKE_REDIRECT_PORT_PREFERENCE = "NotificationLifecycleController.invokeRedirectPort";

    private static final String SUCCESS_PATH = "/scripts/success.json";

    private final Log log = LogFactory.getLog(getClass());

    @Autowired
    private UsernameFinder usernameFinder;

    @Autowired
    private NotificationResponseFlattener notificationResponseFlattener;

    @Resource(name="rootNotificationService")
    private INotificationService notificationService;

    @ResourceMapping("GET-NOTIFICATIONS-UNCATEGORIZED")
    public ModelAndView getNotificationsUncategorized(final ResourceRequest req, final @RequestParam(value="refresh", required=false) String doRefresh) throws IOException {

        // RequestParam("key") String key, HttpServletRequest request, ModelMap model
        log.debug("Invoking getNotifications for user:  " + usernameFinder.findUsername(req));

        // Get the notifications and any data retrieval errors
        final NotificationResponse notifications = notificationService.fetch(req);
        if (notifications == null) {
            String msg = "Notifications have not been loaded for user:  " + usernameFinder.findUsername(req);
            throw new IllegalStateException(msg);
        }

        // Flatten the collection
        List<NotificationEntry> allEntries = notificationResponseFlattener.flatten(notifications);

        // Apply specified sorting (if any)...
        allEntries = Sorting.sort(req, allEntries);

        final Map<String,Object> model = new HashMap<>();
        model.put("feed", allEntries);
        model.put("errors", notifications.getErrors());
        return new ModelAndView("json", model);

    }

    @ActionMapping(params="action=invokeNotificationService")
    public void invokeNotificationService(final ActionRequest req, final ActionResponse res,
            @RequestParam(value="refresh", required=false) final String doRefresh)
            throws IOException {

        // Notification data services must have the invoke() method called,
        // whether we're using portlet events or not;  additional features --
        // including the refresh button -- rely on invoke().
        notificationService.invoke(req, res, Boolean.parseBoolean(doRefresh));

        final PortletPreferences prefs = req.getPreferences();
        final boolean doEvents = Boolean.parseBoolean(prefs.getValue(DO_EVENTS_PREFERENCE, "false"));
        if (doEvents) {

            /*
             * TODO:  I wish we didn't have to go through a whole render phase just
             * to trigger the events-based features of the portlet, but atm I don't
             * see a way around it, since..
             *
             *   - (1) You can only start an event chain in the Action phase;  and
             *   - (2) You can only return JSON in a Resource phase;  and
             *   - (3) An un-redirected Action phase leads to a Render phase, not a
             *     Resource phase :(
             *
             * It would be awesome either (first choice) to do Action > Event > Resource,
             * or Action > sendRedirect() followed by a Resource request.
             *
             * As it stands, this implementation will trigger a complete render on
             * the portal needlessly.
             */

        } else {
            // The real payload awaits a Render phase;  send a token response to
            // avoid a full portlet request cycle (since we can).
            final String redirectUri = evaluateRedirectUri(req);
            res.sendRedirect(redirectUri);
        }

    }

    @ActionMapping
    public void invokeUserAction(final ActionRequest req, final ActionResponse res,
            @RequestParam("notificationId") final String notificationId,
            @RequestParam("actionId") final String actionId) throws IOException {

        // Prime the pump
        notificationService.invoke(req, res, false);

        // Obtain the collection
        final NotificationResponse notifications = notificationService.fetch(req);

        // Find the relevant action
        NotificationAction target = null;
        final NotificationEntry entry = notifications.findNotificationEntryById(notificationId);
        if (entry != null) {
            for (NotificationAction action : entry.getAvailableActions()) {
                if (actionId.equals(action.getId())) {
                    target = action;
                    break;
                }
            }
        }

        // We must have a target to proceed
        if (target != null) {
            target.invoke(req, res);
            // It's reasonable to assume we need to purge
            // caches for this user after invoking his action
            notificationService.invoke(req, res, true);
        } else {
            String msg = "Target action not found for notificationId='"
                    + notificationId + "' and actionId='" + actionId + "'";
            log.warn(msg);
        }

    }

    @EventMapping(NotificationConstants.NOTIFICATION_RESULT_QNAME_STRING)
    public void collectNotifications(final EventRequest req, final EventResponse res) {

        final PortletPreferences prefs = req.getPreferences();
        final boolean doEvents = Boolean.parseBoolean(prefs.getValue(DO_EVENTS_PREFERENCE, "false"));
        if (!doEvents) {
            // Get out...
            return;
        }

        if (log.isDebugEnabled()) {
            log.debug("Processing event=" + NotificationConstants.NOTIFICATION_RESULT_QNAME_STRING +" for user='"
                                + usernameFinder.findUsername(req)
                                + "' and windowId=" + req.getWindowID());
        }

        // Ignore results from other notification portlets
        final NotificationResult notificationResult = (NotificationResult) req.getEvent().getValue();
        if (notificationResult != null && req.getWindowID().equals(notificationResult.getQueryWindowId())) {
            notificationService.collect(req, res);
        }

    }

    /*
     * Implementation
     */

    private String evaluateRedirectUri(ActionRequest req) {

        // Default response -- specify a relative URI, allowing the protocol to be inferred
        String rslt = req.getContextPath() + SUCCESS_PATH;

        final PortletPreferences prefs = req.getPreferences();
        final String protocol = prefs.getValue(INVOKE_REDIRECT_PROTOCOL_PREFERENCE, null);
        if (protocol != null) {
            // Specify an absolute URI.  Apparently we need to insist on a protoco (usually HTTPS)
            String portPart = "";  // default
            final String port = prefs.getValue(INVOKE_REDIRECT_PORT_PREFERENCE, null);
            if (port != null) {
                portPart = ":" + port;
            }
            rslt = protocol.toLowerCase() + "://"
                    + req.getServerName()   // Server hostname
                    + portPart              // Server port (blank, with any luck)
                    + rslt;                 // Remainder of the URI (as above)
        }

        return rslt;

    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.action.read;

import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.portlet.ActionRequest;
import javax.portlet.ActionResponse;
import javax.portlet.PortletPreferences;
import javax.portlet.PortletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.jasig.portlet.notice.NotificationState;
import org.jasig.portlet.notice.rest.EventDTO;
import org.jasig.portlet.notice.util.JpaServices;
import org.jasig.portlet.notice.util.SpringContext;
import org.jasig.portlet.notice.util.UsernameFinder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.commons.lang.builder.HashCodeBuilder;
import org.jasig.portlet.notice.NotificationAction;
import org.jasig.portlet.notice.NotificationEntry;

public class ReadAction extends NotificationAction {

    private static final long serialVersionUID = 1L;

    protected static Logger logger = LoggerFactory.getLogger(ReadAction.class);

    /**
     * This INSTANCE is only for convenience -- READ and UNREAD not singletons.
     * There may be situations where de-serialization will create additional
     * instances, and that's okay.
     */
    public static final ReadAction READ = new ReadAction();

    /**
     * Stores the Ids of read notices.
     */
    private static final String READ_NOTIFICATION_IDS_PREFERENCE =
            ReadAction.class.getName() + ".READ_NOTIFICATION_IDS_PREFERENCE";

    /**
     * Must remain public, no-arg for de-serialization.
     */
    public ReadAction() {
        // Set a default label;  most use cases will use the setter and override
        setLabel("MARK AS READ");
    }

    public ReadAction(String label){
        setLabel(label);
    }

    public static final ReadAction createReadInstance() {
        return new ReadAction();
    }

    public static final ReadAction createUnReadInstance() {
        return new ReadAction("MARK AS UNREAD");
    }

    /**
     * Invoking a ReadAction toggles it.
     */
    @Override
    public void invoke(final ActionRequest req, final ActionResponse res) throws IOException {
        final NotificationEntry entry = getTarget();
        final String notificationId = entry.getId();
        final Set<String> readNotices = this.getReadNotices(req);
        if (readNotices.contains(notificationId)) {
            readNotices.remove(notificationId);
        } else {
            readNotices.add(notificationId);
        }
        setReadNotices(req, readNotices);
    }

    @Override
    public void invoke(HttpServletRequest request, HttpServletResponse response) throws IOException {
        final NotificationEntry entry = getTarget();
        final String username =
                ((UsernameFinder) SpringContext.getApplicationContext().getBean("usernameFinder"))
                        .findUsername(request);
        if (!isRead(entry, username)) {
            // NB:  Currently there is no way to un-apply the READ state
            final JpaServices jpaServices = (JpaServices) SpringContext.getApplicationContext().getBean("jpaServices");
            jpaServices.applyState(entry, username, NotificationState.READ);
        }
    }

    @Override
    public int hashCode(){
        return new HashCodeBuilder(17, 31).
                append(super.getId()).
                toHashCode();
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        // At present, any instance ReadAction is equal to another
        return true;
    }

    public void removeReadNotices (final PortletRequest req, Set<String> idsToRemove) {
        Set<String> currentIds = getReadNotices(req);
        currentIds.removeAll(idsToRemove);
        setReadNotices(req, currentIds);
    }

    /*
     * Non-public API
     */

    /* package-private */ Set<String> getReadNotices(final PortletRequest req) {
        final HashSet<String> rslt = new HashSet<>();
        final PortletPreferences prefs = req.getPreferences();
        final String[] ids = prefs.getValues(READ_NOTIFICATION_IDS_PREFERENCE, new String[0]);
        for (int i=0; i < ids.length; i++) {
            rslt.add(ids[i]);
        }
        return rslt;
    }

    /* package-private */ void setReadNotices(final PortletRequest req, final Set<String> favoriteNotices) {
        final String[] ids = favoriteNotices.toArray(new String[favoriteNotices.size()]);
        final PortletPreferences prefs = req.getPreferences();
        try {
            prefs.setValues(READ_NOTIFICATION_IDS_PREFERENCE, ids);
            prefs.store();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    protected boolean isRead(NotificationEntry entry, String username) {

        final JpaServices jpaServices = (JpaServices) SpringContext.getApplicationContext().getBean("jpaServices");
        final List<EventDTO> history = jpaServices.getHistory(entry, username);

        boolean rslt = false;  // default (clearly)

        // Review the history...
        for (EventDTO event : history) {
            if (NotificationState.READ.equals(event.getState())) {
                logger.debug("Found a READ event:  {}", event);
                rslt = true;
                // NB:  Currently there is no way to un-apply the READ state
                break;
            }
        }

        return rslt;

    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service.jpa.action;

import java.io.IOException;
import java.util.Date;
import java.util.Map;
import javax.portlet.ActionRequest;
import javax.portlet.ActionResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.jasig.portlet.notice.NotificationAction;
import org.jasig.portlet.notice.NotificationEntry;
import org.jasig.portlet.notice.NotificationState;
import org.jasig.portlet.notice.service.CacheNotificationService;
import org.jasig.portlet.notice.service.jpa.JpaNotificationService;
import org.jasig.portlet.notice.util.SpringContext;

/**
 *
 * @author mglazier
 */
public class CompleteOnRedirectAction extends NotificationAction {

    private static final long serialVersionUID = 1L;

    public CompleteOnRedirectAction() {
        // Provide a sensible (default) label;  most
        // use cases will use the setter and override
        setLabel("COMPLETE");
    }

    public CompleteOnRedirectAction(String label) {
        setLabel(label);
    }

    /**
     * When invoke is called, a configured notification state is set for the entry if it has not already been set.
     * {@link JpaNotificationService} and {@link CacheNotificationService} are used here to add the entry state and clear
     * the cache for the user.  This class is not managed by Spring, so these objects must be obtained using the
     * Spring context that {@code SpringContext} provides.
     *
     * @param req
     * @param res
     * @throws IOException
     */
    @Override
    public void invoke(final ActionRequest req, final ActionResponse res) throws IOException {
        JpaNotificationService jpaService = (JpaNotificationService) SpringContext.getApplicationContext().getBean("jpaNotificationService");

        final NotificationEntry entry = getTarget();
        Map<NotificationState, Date> stateMap = entry.getStates();
        if (stateMap != null && !stateMap.containsKey(NotificationState.COMPLETED)) {
            jpaService.addEntryState(req, entry.getId(), NotificationState.COMPLETED);
        }

        res.sendRedirect(entry.getUrl());
    }

    @Override
    public void invoke(HttpServletRequest request, HttpServletResponse response) {
        throw new UnsupportedOperationException("TODO:  Implement!");
    }

}


/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.jasig.portlet.notice.service.classloader;

import java.io.File;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.portlet.PortletPreferences;
import javax.portlet.PortletRequest;
import javax.servlet.http.HttpServletRequest;

import net.sf.ehcache.Cache;
import net.sf.ehcache.Element;

import org.apache.commons.lang3.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jasig.portlet.notice.NotificationResponse;
import org.jasig.portlet.notice.service.AbstractNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;

/**
 * This is a simple service provider that reads notifications from one or more
 * files in the classpath.  It's a handy way to implement, for example, an
 * online reference or FAQ using the Notification portlet.  It also serves as
 * the basis for the {@link DemoNotificationService}.
 */
public class ClassLoaderResourceNotificationService extends AbstractNotificationService {

    public static final String LOCATIONS_PREFERENCE = "ClassLoaderResourceNotificationService.locations";

    /**
     * Classpath locations defined in an external file, in the post-Portlet API style.
     * Comma-separated list.
     */
    @Value("${ClassLoaderResourceNotificationService.locations:}")
    private String locationsProperty;

    private List<String> locationsList = Collections.emptyList();
    private final ObjectMapper mapper = new ObjectMapper();
    private final Logger logger = LoggerFactory.getLogger(getClass());

    private Cache cache;

    @Resource(name="ClassLoaderResourceNotificationService.responseCache")
    public void setCache(Cache cache) {
        this.cache = cache;
    }

    @PostConstruct
    public void init() {
        if (!StringUtils.isEmpty(locationsProperty)) {
            locationsList =
                    Collections.unmodifiableList(Arrays.asList(locationsProperty.split(",")));
        }
    }

    @Override
    public NotificationResponse fetch(PortletRequest req) {

        final List<String> locations = getLocations(req);
        return fetchFromClasspath(locations);

    }

    @Override
    public final boolean isValid(PortletRequest req, NotificationResponse previousResponse) {
        /*
         * This service impl privately caches responses across users and for
         * longer periods, so there's no harm in calling fetch() whenever a
         * response is needed.
         */
        return false;
    }

    @Override
    public NotificationResponse fetch(HttpServletRequest request) {
        final List<String> locations = getLocations(request);
        return fetchFromClasspath(locations);
    }

    /*
     * Implementation
     */

    private NotificationResponse fetchFromClasspath(List<String> locations) {

        if (locations.isEmpty()) {
            return NotificationResponse.EMPTY_RESPONSE;
        }

        NotificationResponse rslt;

        final Element m = cache.get(locations);
        if (m != null) {
            // ## CACHE HIT ##
            rslt = (NotificationResponse) m.getObjectValue();
            logger.debug("Locations cache HIT for collection {};  size={}", locations, rslt.size());
        } else {
            // ## CACHE MISS ##
            rslt = new NotificationResponse();
            for (String loc : locations) {
                final NotificationResponse response = readFromFile(loc);
                rslt = rslt.combine(response);
            }
            logger.debug("Locations cache MISS for collection {};  size={}", locations, rslt.size());
            cache.put(new Element(locations, rslt));
        }

        return rslt;

    }

    @Deprecated
    protected List<String> getLocations(PortletRequest req) {
        final PortletPreferences prefs = req.getPreferences();
        final String[] locations = prefs.getValues(LOCATIONS_PREFERENCE, new String[0]);
        final ArrayList<String> rslt = new ArrayList<>(Arrays.asList(locations));
        return rslt;
    }

    /**
     * Access to the list of locations is wrapped in a method so that subclasses can override it.
     */
    protected List<String> getLocations(HttpServletRequest req) {
        return locationsList;
    }

    /**
     * Deserialize the given JSON formatted file back into a object.
     *
     * @param filename The path and name of the file to be read.
     * @return NotificationRequest, null if the de-serialization fails.
     */
    private NotificationResponse readFromFile(String filename) {

        NotificationResponse rslt;

        logger.debug("Preparing to read from file:  {}", filename);

        URL location = getClass().getClassLoader().getResource(filename);
        if (location != null) {
            try {
                File f = new File(location.toURI());
                rslt =  mapper.readValue(f, NotificationResponse.class);
            } catch (Exception e) {
                String msg = "Failed to read the data file:  " + location;
                logger.error(msg, e);
                rslt = prepareErrorResponse(getName(), msg);
            }
        } else {
            String msg = "Data file not found:  " + filename;
            rslt = prepareErrorResponse(getName(), msg);
        }

        return rslt;

    }

}