package org.apereo.cas.ticket.registry.support;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EntityManager;
import javax.persistence.Id;
import javax.persistence.LockModeType;
import javax.persistence.PersistenceContext;
import javax.persistence.Table;
import javax.persistence.Version;
import java.io.Serializable;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;

/**
 * JPA 2.0 implementation of an exclusive, non-reentrant lock.
 *
 * @author Marvin S. Addison
 * @since 3.0.0
 */
@Transactional(transactionManager = "ticketTransactionManager")
public class JpaLockingStrategy implements LockingStrategy {
    
    private static final Logger LOGGER = LoggerFactory.getLogger(JpaLockingStrategy.class);
    
    /** Transactional entity manager from Spring context. */
    @PersistenceContext(unitName = "ticketEntityManagerFactory")
    protected EntityManager entityManager;
    
    /**
     * Application identifier that identifies rows in the locking table,
     * each one of which may be for a different application or usage within
     * a single application.
     */
    private String applicationId;

    /** Unique identifier that identifies the client using this lock instance. */
    private String uniqueId;

    /** Amount of time in seconds lock may be held. */
    private long lockTimeout;

    /**
     *
     * @param applicationId Application identifier that identifies a row in the lock
     *             table for which multiple clients vie to hold the lock.
     *             This must be the same for all clients contending for a
     *             particular lock.
     * @param uniqueId Identifier used to identify this instance in a row of the
     *             lock table.  Must be unique across all clients vying for
     *             locks for a given application ID.
     * @param lockTimeout Maximum amount of time in seconds lock may be held.
     *                  A value of zero indicates that locks are held indefinitely.
     *                  Use of a reasonable timeout facilitates recovery from node failures,
     *                  so setting to zero is discouraged.
     */
    public JpaLockingStrategy(final String applicationId, final String uniqueId, final long lockTimeout) {
        this.applicationId = applicationId;
        this.uniqueId = uniqueId;
        if (lockTimeout < 0) {
            throw new IllegalArgumentException("Lock timeout must be non-negative.");
        }
        this.lockTimeout = lockTimeout;
    }

    @Override
    public boolean acquire() {
        final Lock lock;
        try {
            lock = this.entityManager.find(Lock.class, this.applicationId, LockModeType.OPTIMISTIC);
        } catch (final Exception e) {
            LOGGER.debug("[{}] failed querying for [{}] lock.", this.uniqueId, this.applicationId, e);
            return false;
        }

        boolean result = false;
        if (lock != null) {
            final ZonedDateTime expDate = lock.getExpirationDate();
            if (lock.getUniqueId() == null) {
                // No one currently possesses lock
                LOGGER.debug("[{}] trying to acquire [{}] lock.", this.uniqueId, this.applicationId);
                result = acquire(lock);
            } else if (expDate == null || ZonedDateTime.now(ZoneOffset.UTC).isAfter(expDate)) {
                // Acquire expired lock regardless of who formerly owned it
                LOGGER.debug("[{}] trying to acquire expired [{}] lock.", this.uniqueId, this.applicationId);
                result = acquire(lock);
            }
        } else {
            // First acquisition attempt for this applicationId
            LOGGER.debug("Creating [{}] lock initially held by [{}].", applicationId, uniqueId);
            result = acquire(new Lock());
        }
        return result;
    }

    @Override
    public void release() {
        final Lock lock = this.entityManager.find(Lock.class, this.applicationId, LockModeType.OPTIMISTIC);

        if (lock == null) {
            return;
        }
        // Only the current owner can release the lock
        final String owner = lock.getUniqueId();
        if (!this.uniqueId.equals(owner)) {
            throw new IllegalStateException("Cannot release lock owned by " + owner);
        }
        lock.setUniqueId(null);
        lock.setExpirationDate(null);
        LOGGER.debug("Releasing [{}] lock held by [{}].", this.applicationId, this.uniqueId);
        this.entityManager.persist(lock);
    }
    
    @Override
    public String toString() {
        return this.uniqueId;
    }

    /**
     * Acquire the lock object.
     *
     * @param lock the lock
     * @return true, if successful
     */
    public boolean acquire(final Lock lock) {
        lock.setUniqueId(this.uniqueId);
        if (this.lockTimeout > 0) {
            lock.setExpirationDate(ZonedDateTime.now(ZoneOffset.UTC).plusSeconds(this.lockTimeout));
        } else {
            lock.setExpirationDate(null);
        }
        boolean success;
        try {
            if (lock.getApplicationId() != null) {
                this.entityManager.merge(lock);
            } else {
                lock.setApplicationId(this.applicationId);
                this.entityManager.persist(lock);
            }
            success = true;
        } catch (final Exception e) {
            success = false;
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("[{}] could not obtain [{}] lock.", this.uniqueId, this.applicationId, e);
            } else {
                LOGGER.info("[{}] could not obtain [{}] lock.", this.uniqueId, this.applicationId);
            }
        }
        return success;
    }


    /**
     * Describes a database lock.
     *
     * @author Marvin S. Addison
     *
     */
    @Entity
    @Table(name = "locks")
    private static class Lock implements Serializable {

        private static final long serialVersionUID = -5750740484289616656L;
        /** column name that holds application identifier. */
        @Id
        @Column(name="application_id")
        private String applicationId;

        /** Database column name that holds unique identifier. */
        @Column(name="unique_id")
        private String uniqueId;

        /** Database column name that holds expiration date. */
        @Column(name="expiration_date")
        private ZonedDateTime expirationDate;

        @Version
        @Column(name = "lockVer", columnDefinition = "integer DEFAULT 0", nullable = false)
        private Long version = 0L;
        
        /**
         * @return the applicationId
         */
        public String getApplicationId() {
            return this.applicationId;
        }

        /**
         * @param applicationId the applicationId to set
         */
        public void setApplicationId(final String applicationId) {
            this.applicationId = applicationId;
        }

        /**
         * @return the uniqueId
         */
        public String getUniqueId() {
            return this.uniqueId;
        }

        /**
         * @param uniqueId the uniqueId to set
         */
        public void setUniqueId(final String uniqueId) {
            this.uniqueId = uniqueId;
        }

        /**
         * @return the expirationDate
         */
        public ZonedDateTime getExpirationDate() {
            return this.expirationDate == null ? null : ZonedDateTime.from(this.expirationDate);
        }

        /**
         * @param expirationDate the expirationDate to set
         */
        public void setExpirationDate(final ZonedDateTime expirationDate) {
            this.expirationDate = expirationDate;
        }
    }
}
