package org.apereo.cas.authentication.principal.cache;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apereo.cas.authentication.principal.Principal;
import org.apereo.cas.authentication.principal.PrincipalAttributesRepository;
import org.apereo.cas.util.spring.ApplicationContextProvider;
import org.apereo.services.persondir.IPersonAttributeDao;
import org.apereo.services.persondir.IPersonAttributes;
import org.apereo.services.persondir.support.merger.IAttributeMerger;
import org.apereo.services.persondir.support.merger.MultivaluedAttributeMerger;
import org.apereo.services.persondir.support.merger.NoncollidingAttributeAdder;
import org.apereo.services.persondir.support.merger.ReplacingAttributeAdder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;

import java.io.Closeable;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * Parent class for retrieval principals attributes, provides operations
 * around caching, merging of attributes.
 *
 * @author Misagh Moayyed
 * @since 4.2
 */
public abstract class AbstractPrincipalAttributesRepository implements PrincipalAttributesRepository, Closeable {
    /**
     * Default cache expiration time unit.
     */
    private static final String DEFAULT_CACHE_EXPIRATION_UNIT = TimeUnit.HOURS.name();

    /**
     * Default expiration lifetime based on the default time unit.
     */
    private static final long DEFAULT_CACHE_EXPIRATION_DURATION = 2;

    private static final long serialVersionUID = 6350245643948535906L;

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

    /**
     * The expiration time.
     */
    protected long expiration;

    /**
     * Expiration time unit.
     */
    protected String timeUnit;

    /**
     * The merging strategy that deals with existing principal attributes
     * and those that are retrieved from the source. By default, existing attributes
     * are ignored and the source is always consulted.
     */
    protected MergingStrategy mergingStrategy;

    /**
     * Defines the merging strategy options.
     */
    public enum MergingStrategy {

        /**
         * Replace attributes.
         */
        REPLACE,
        /**
         * Add attributes.
         */
        ADD,
        /**
         * No merging.
         */
        NONE,
        /**
         * Multivalued attributes.
         */
        MULTIVALUED;

        /**
         * Get attribute merger.
         *
         * @return the attribute merger
         */
        public IAttributeMerger getAttributeMerger() {
            final String name = this.name().toUpperCase();

            switch (name.toUpperCase()) {
                case "REPLACE":
                    return new ReplacingAttributeAdder();
                case "ADD":
                    return new NoncollidingAttributeAdder();
                case "MULTIVALUED":
                    return new MultivaluedAttributeMerger();
                default:
                    return null;
            }
        }

    }

    private transient IPersonAttributeDao attributeRepository;


    /**
     * Instantiates a new principal attributes repository.
     * Simply used buy
     */
    protected AbstractPrincipalAttributesRepository() {
        this(DEFAULT_CACHE_EXPIRATION_DURATION, DEFAULT_CACHE_EXPIRATION_UNIT);
    }

    /**
     * Instantiates a new principal attributes repository.
     *
     * @param expiration the expiration
     * @param timeUnit   the time unit
     */
    public AbstractPrincipalAttributesRepository(final long expiration, final String timeUnit) {
        this.expiration = expiration;
        this.timeUnit = timeUnit;
    }

    /**
     * The merging strategy that deals with existing principal attributes
     * and those that are retrieved from the source. By default, existing attributes
     * are ignored and the source is always consulted.
     *
     * @param mergingStrategy the strategy to use for conflicts
     */
    public void setMergingStrategy(final MergingStrategy mergingStrategy) {
        this.mergingStrategy = mergingStrategy;
    }

    public MergingStrategy getMergingStrategy() {
        return this.mergingStrategy;
    }

    /**
     * Convert person attributes to principal attributes.
     *
     * @param attributes person attributes
     * @return principal attributes
     */
    protected Map<String, Object> convertPersonAttributesToPrincipalAttributes(
            final Map<String, List<Object>> attributes) {
        return attributes.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey,
                        entry -> entry.getValue().size() == 1 ? entry.getValue().get(0) : entry.getValue(), (e, f) -> f == null ? e : f));
    }

    /***
     * Convert principal attributes to person attributes.
     * @param p  the principal carrying attributes
     * @return person attributes
     */
    private static Map<String, List<Object>> convertPrincipalAttributesToPersonAttributes(final Principal p) {
        final Map<String, List<Object>> convertedAttributes = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
        final Map<String, Object> principalAttributes = p.getAttributes();

        principalAttributes.entrySet().stream().forEach(entry -> {
            final Object values = entry.getValue();
            final String key = entry.getKey();
            if (values instanceof List) {
                convertedAttributes.put(key, (List) values);
            } else {
                convertedAttributes.put(key, Collections.singletonList(values));
            }
        });
        return convertedAttributes;
    }

    /**
     * Obtains attributes first from the repository by calling
     * {@link org.apereo.services.persondir.IPersonAttributeDao#getPerson(String)}.
     *
     * @param id the person id to locate in the attribute repository
     * @return the map of attributes
     */
    protected Map<String, List<Object>> retrievePersonAttributesToPrincipalAttributes(final String id) {

        final IPersonAttributes attrs = getAttributeRepository().getPerson(id);

        if (attrs == null) {
            LOGGER.debug("Could not find principal [{}] in the repository so no attributes are returned.", id);
            return Collections.emptyMap();
        }

        final Map<String, List<Object>> attributes = attrs.getAttributes();
        if (attributes == null) {
            LOGGER.debug("Principal [{}] has no attributes and so none are returned.", id);
            return Collections.emptyMap();
        }
        return attributes;
    }

    @Override
    public Map<String, Object> getAttributes(final Principal p) {
        final Map<String, Object> cachedAttributes = getPrincipalAttributes(p);
        if (cachedAttributes != null && !cachedAttributes.isEmpty()) {
            LOGGER.debug("Found [{}] cached attributes for principal [{}] that are [{}]", cachedAttributes.size(), p.getId(),
                    cachedAttributes);
            return cachedAttributes;
        }

        if (getAttributeRepository() == null) {
            LOGGER.debug("No attribute repository is defined for [{}]. Returning default principal attributes for [{}]",
                    getClass().getName(), p.getId());
            return cachedAttributes;
        }

        final Map<String, List<Object>> sourceAttributes = retrievePersonAttributesToPrincipalAttributes(p.getId());
        LOGGER.debug("Found [{}] attributes for principal [{}] from the attribute repository.",
                sourceAttributes.size(), p.getId());

        if (this.mergingStrategy == null || this.mergingStrategy.getAttributeMerger() == null) {
            LOGGER.debug("No merging strategy found, so attributes retrieved from the repository will be used instead.");
            return convertAttributesToPrincipalAttributesAndCache(p, sourceAttributes);
        }

        final Map<String, List<Object>> principalAttributes = convertPrincipalAttributesToPersonAttributes(p);

        LOGGER.debug("Merging current principal attributes with that of the repository via strategy [{}]",
                this.mergingStrategy.getClass().getSimpleName());

        try {
            final Map<String, List<Object>> mergedAttributes =
                    this.mergingStrategy.getAttributeMerger().mergeAttributes(principalAttributes, sourceAttributes);
            return convertAttributesToPrincipalAttributesAndCache(p, mergedAttributes);
        } catch (final Exception e) {
            final StringBuilder builder = new StringBuilder();
            builder.append(e.getClass().getName().concat("-"));
            if (StringUtils.isNotBlank(e.getMessage())) {
                builder.append(e.getMessage());
            }

            LOGGER.error("The merging strategy [{}] for [{}] has failed to produce principal attributes because: [{}]. "
                            + "This usually is indicative of a bug and/or configuration mismatch. CAS will skip the merging process "
                            + "and will return the original collection of principal attributes [{}]",
                    this.mergingStrategy,
                    p.getId(),
                    builder.toString(),
                    principalAttributes);
            return convertAttributesToPrincipalAttributesAndCache(p, principalAttributes);
        }
    }

    /**
     * Convert attributes to principal attributes and cache.
     *
     * @param p                the p
     * @param sourceAttributes the source attributes
     * @return the map
     */
    private Map<String, Object> convertAttributesToPrincipalAttributesAndCache(final Principal p,
                                                                               final Map<String, List<Object>> sourceAttributes) {
        final Map<String, Object> finalAttributes = convertPersonAttributesToPrincipalAttributes(sourceAttributes);
        addPrincipalAttributes(p.getId(), finalAttributes);
        return finalAttributes;
    }

    /**
     * Add principal attributes into the underlying cache instance.
     *
     * @param id         identifier used by the cache as key.
     * @param attributes attributes to cache
     * @since 4.2
     */
    protected abstract void addPrincipalAttributes(String id, Map<String, Object> attributes);

    /**
     * Gets principal attributes from cache.
     *
     * @param p the principal
     * @return the principal attributes from cache
     */
    protected abstract Map<String, Object> getPrincipalAttributes(Principal p);

    public void setAttributeRepository(final IPersonAttributeDao attributeRepository) {
        this.attributeRepository = attributeRepository;
    }

    private IPersonAttributeDao getAttributeRepository() {
        if (this.attributeRepository == null) {
            final ApplicationContext context = ApplicationContextProvider.getApplicationContext();
            if (context != null) {
                return context.getBean("attributeRepository", IPersonAttributeDao.class);
            }
            LOGGER.warn("No application context could be retrieved, so no attribute repository instance can be determined.");
        }
        return this.attributeRepository;
    }

    @Override
    public String toString() {
        return new ToStringBuilder(this)
                .append("mergingStrategy", this.mergingStrategy)
                .append("expiration", this.expiration)
                .append("timeUnit", this.timeUnit)
                .toString();
    }

    public long getExpiration() {
        return this.expiration;
    }

    public String getTimeUnit() {
        return this.timeUnit;
    }

    public void setTimeUnit(final String unit) {
        this.timeUnit = unit;
    }

    public void setExpiration(final long expiration) {
        this.expiration = expiration;
    }

    @Override
    public boolean equals(final Object obj) {
        if (obj == null) {
            return false;
        }
        if (obj == this) {
            return true;
        }
        if (obj.getClass() != getClass()) {
            return false;
        }
        final AbstractPrincipalAttributesRepository rhs = (AbstractPrincipalAttributesRepository) obj;
        return new EqualsBuilder()
                .append(this.timeUnit, rhs.timeUnit)
                .append(this.expiration, rhs.expiration)
                .isEquals();
    }

    @Override
    public int hashCode() {
        return new HashCodeBuilder(13, 133)
                .append(this.timeUnit)
                .append(this.expiration)
                .toHashCode();
    }
}
