package org.apereo.cas;

import com.codahale.metrics.annotation.Counted;
import com.codahale.metrics.annotation.Metered;
import com.codahale.metrics.annotation.Timed;
import org.apereo.cas.authentication.Authentication;
import org.apereo.cas.authentication.AuthenticationServiceSelectionPlan;
import org.apereo.cas.authentication.ContextualAuthenticationPolicy;
import org.apereo.cas.authentication.ContextualAuthenticationPolicyFactory;
import org.apereo.cas.authentication.policy.AcceptAnyAuthenticationPolicyFactory;
import org.apereo.cas.authentication.principal.DefaultPrincipalFactory;
import org.apereo.cas.authentication.principal.PrincipalFactory;
import org.apereo.cas.authentication.principal.Service;
import org.apereo.cas.logout.LogoutManager;
import org.apereo.cas.services.RegisteredService;
import org.apereo.cas.services.ServiceContext;
import org.apereo.cas.services.ServicesManager;
import org.apereo.cas.services.UnauthorizedProxyingException;
import org.apereo.cas.ticket.AbstractTicketException;
import org.apereo.cas.ticket.InvalidTicketException;
import org.apereo.cas.ticket.Ticket;
import org.apereo.cas.ticket.TicketFactory;
import org.apereo.cas.ticket.TicketGrantingTicket;
import org.apereo.cas.ticket.UnsatisfiedAuthenticationPolicyException;
import org.apereo.cas.ticket.registry.TicketRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

import java.io.Serializable;
import java.util.Collection;
import java.util.function.Predicate;
import java.util.stream.Collectors;

/**
 * An abstract implementation of the {@link CentralAuthenticationService} that provides access to
 * the needed scaffolding and services that are necessary to CAS, such as ticket registry, service registry, etc.
 * The intention here is to allow extensions to easily benefit these already-configured components
 * without having to to duplicate them again.
 *
 * @author Misagh Moayyed
 * @since 4.2.0
 */
public abstract class AbstractCentralAuthenticationService implements CentralAuthenticationService, Serializable,
        ApplicationEventPublisherAware {

    private static final long serialVersionUID = -7572316677901391166L;

    private static final Logger LOGGER = LoggerFactory.getLogger(AbstractCentralAuthenticationService.class);

    /**
     * Application event publisher.
     */
    @Autowired
    protected ApplicationEventPublisher applicationEventPublisher;

    /**
     * {@link TicketRegistry}  for storing and retrieving tickets as needed.
     */
    protected TicketRegistry ticketRegistry;

    /**
     * Implementation of Service Manager.
     */
    protected ServicesManager servicesManager;

    /**
     * The logout manager.
     **/
    protected LogoutManager logoutManager;

    /**
     * The ticket factory.
     **/
    protected TicketFactory ticketFactory;

    /**
     * The service selection strategy during validation events.
     **/
    protected final AuthenticationServiceSelectionPlan authenticationRequestServiceSelectionStrategies;

    /**
     * Authentication policy that uses a service context to produce stateful security policies to apply when
     * authenticating credentials.
     */
    protected ContextualAuthenticationPolicyFactory<ServiceContext> serviceContextAuthenticationPolicyFactory
            = new AcceptAnyAuthenticationPolicyFactory();

    /**
     * Factory to create the principal type.
     **/
    protected PrincipalFactory principalFactory = new DefaultPrincipalFactory();

    /**
     * Cipher executor to handle ticket validation.
     */
    protected CipherExecutor<String, String> cipherExecutor;

    /**
     * Build the central authentication service implementation.
     *
     * @param ticketRegistry      the tickets registry.
     * @param ticketFactory       the ticket factory
     * @param servicesManager     the services manager.
     * @param logoutManager       the logout manager.
     * @param selectionStrategies the selection strategies
     * @param policy              Authentication policy uses a service context to create security policies to apply when
     *                            authenticating credentials.
     * @param principalFactory    principal factory to create principal objects
     * @param cipherExecutor      Cipher executor to handle ticket validation.
     */
    public AbstractCentralAuthenticationService(final TicketRegistry ticketRegistry,
                                                final TicketFactory ticketFactory, final ServicesManager servicesManager,
                                                final LogoutManager logoutManager,
                                                final AuthenticationServiceSelectionPlan selectionStrategies,
                                                final ContextualAuthenticationPolicyFactory<ServiceContext> policy,
                                                final PrincipalFactory principalFactory,
                                                final CipherExecutor<String, String> cipherExecutor) {
        this.ticketRegistry = ticketRegistry;
        this.servicesManager = servicesManager;
        this.logoutManager = logoutManager;
        this.ticketFactory = ticketFactory;
        this.authenticationRequestServiceSelectionStrategies = selectionStrategies;
        this.principalFactory = principalFactory;
        this.serviceContextAuthenticationPolicyFactory = policy;
        this.cipherExecutor = cipherExecutor;
    }

    /**
     * Publish CAS events.
     *
     * @param e the event
     */
    protected void doPublishEvent(final ApplicationEvent e) {
        if (applicationEventPublisher != null) {
            LOGGER.debug("Publishing [{}]", e);
            this.applicationEventPublisher.publishEvent(e);
        }
    }

    @Transactional(transactionManager = "ticketTransactionManager",
            noRollbackFor = InvalidTicketException.class)
    @Timed(name = "GET_TICKET_TIMER")
    @Metered(name = "GET_TICKET_METER")
    @Counted(name = "GET_TICKET_COUNTER", monotonic = true)
    @Override
    public <T extends Ticket> T getTicket(final String ticketId) throws InvalidTicketException {
        Assert.notNull(ticketId, "ticketId cannot be null");
        final Ticket ticket = this.ticketRegistry.getTicket(ticketId);
        verifyTicketState(ticket, ticketId, null);
        return (T) ticket;
    }

    /**
     * {@inheritDoc}
     * <p>
     * Note:
     * Synchronization on ticket object in case of cache based registry doesn't serialize
     * access to critical section. The reason is that cache pulls serialized data and
     * builds new object, most likely for each pull. Is this synchronization needed here?
     */
    @Transactional(transactionManager = "ticketTransactionManager", noRollbackFor = InvalidTicketException.class)
    @Timed(name = "GET_TICKET_TIMER")
    @Metered(name = "GET_TICKET_METER")
    @Counted(name = "GET_TICKET_COUNTER", monotonic = true)
    @Override
    public <T extends Ticket> T getTicket(final String ticketId, final Class<T> clazz) throws InvalidTicketException {
        Assert.notNull(ticketId, "ticketId cannot be null");
        final Ticket ticket = this.ticketRegistry.getTicket(ticketId, clazz);
        verifyTicketState(ticket, ticketId, clazz);
        return (T) ticket;
    }

    @Transactional(transactionManager = "ticketTransactionManager")
    @Timed(name = "GET_TICKETS_TIMER")
    @Metered(name = "GET_TICKETS_METER")
    @Counted(name = "GET_TICKETS_COUNTER", monotonic = true)
    @Override
    public Collection<Ticket> getTickets(final Predicate<Ticket> predicate) {
        return this.ticketRegistry.getTickets().stream()
                .filter(predicate)
                .collect(Collectors.toSet());
    }

    @Transactional(transactionManager = "ticketTransactionManager", readOnly = false)
    @Timed(name = "DELETE_TICKET_TIMER")
    @Metered(name = "DELETE_TICKET_METER")
    @Counted(name = "DELETE_TICKET_COUNTER", monotonic = true)
    @Override
    public void deleteTicket(final String ticketId) {
        this.ticketRegistry.deleteTicket(ticketId);
    }

    /**
     * Gets the authentication satisfied by policy.
     *
     * @param authentication the authentication
     * @param context        the context
     * @return the authentication satisfied by policy
     * @throws AbstractTicketException the ticket exception
     */
    protected Authentication getAuthenticationSatisfiedByPolicy(final Authentication authentication, final ServiceContext context)
            throws AbstractTicketException {

        final ContextualAuthenticationPolicy<ServiceContext> policy = this.serviceContextAuthenticationPolicyFactory.createPolicy(context);
        if (policy.isSatisfiedBy(authentication)) {
            return authentication;
        }
        throw new UnsatisfiedAuthenticationPolicyException(policy);
    }

    /**
     * Evaluate proxied service if needed.
     *
     * @param service              the service
     * @param ticketGrantingTicket the ticket granting ticket
     * @param registeredService    the registered service
     */
    protected void evaluateProxiedServiceIfNeeded(final Service service, final TicketGrantingTicket ticketGrantingTicket,
                                                  final RegisteredService registeredService) {
        final Service proxiedBy = ticketGrantingTicket.getProxiedBy();
        if (proxiedBy != null) {
            LOGGER.debug("TGT is proxied by [{}]. Locating proxy service in registry...", proxiedBy.getId());
            final RegisteredService proxyingService = this.servicesManager.findServiceBy(proxiedBy);

            if (proxyingService != null) {
                LOGGER.debug("Located proxying service [{}] in the service registry", proxyingService);
                if (!proxyingService.getProxyPolicy().isAllowedToProxy()) {
                    LOGGER.warn("Found proxying service [{}], but it is not authorized to fulfill the proxy attempt made by [{}]",
                            proxyingService.getId(), service.getId());
                    throw new UnauthorizedProxyingException(UnauthorizedProxyingException.MESSAGE
                            + registeredService.getId());
                }
            } else {
                LOGGER.warn("No proxying service found. Proxy attempt by service [{}] (registered service [{}]) is not allowed.",
                        service.getId(), registeredService.getId());
                throw new UnauthorizedProxyingException(UnauthorizedProxyingException.MESSAGE
                        + registeredService.getId());
            }
        } else {
            LOGGER.trace("TGT is not proxied by another service");
        }
    }

    /**
     * Validate ticket expiration policy and throws exception if ticket is no longer valid.
     * Expired tickets are also deleted from the registry immediately on demand.
     *
     * @param ticket the ticket
     * @param id     the original id
     * @param clazz  the clazz
     */
    protected void verifyTicketState(final Ticket ticket, final String id, final Class clazz) {
        if (ticket == null) {
            LOGGER.debug("Ticket [{}] by type [{}] cannot be found in the ticket registry.", id,
                    clazz != null ? clazz.getSimpleName() : "unspecified");
            throw new InvalidTicketException(id);
        }
        synchronized (ticket) {
            if (ticket.isExpired()) {
                deleteTicket(id);
                LOGGER.debug("Ticket [{}] has expired and is now deleted from the ticket registry.", ticket);
                throw new InvalidTicketException(id);
            }
        }
    }

    @Override
    public Ticket updateTicket(final Ticket ticket) {
        this.ticketRegistry.updateTicket(ticket);
        return ticket;
    }

    /**
     * Resolve service from authentication request.
     *
     * @param service the service
     * @return the service
     */
    protected Service resolveServiceFromAuthenticationRequest(final Service service) {
        return authenticationRequestServiceSelectionStrategies.resolveService(service);
    }

    /**
     * Verify the ticket id received is actually legitimate
     * before contacting downstream systems to find and process it.
     *
     * @param ticketId the ticket id
     * @return true/false
     */
    protected boolean isTicketAuthenticityVerified(final String ticketId) {
        if (this.cipherExecutor != null) {
            LOGGER.debug("Attempting to decode service ticket [{}] to verify authenticity", ticketId);
            return !StringUtils.isEmpty(this.cipherExecutor.decode(ticketId));
        }
        return !StringUtils.isEmpty(ticketId);
    }

    @Override
    public void setApplicationEventPublisher(final ApplicationEventPublisher applicationEventPublisher) {
        this.applicationEventPublisher = applicationEventPublisher;
    }
}
