/**
 * Copyright (c) 2012-2017, jcabi.com
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met: 1) Redistributions of source code must retain the above
 * copyright notice, this list of conditions and the following
 * disclaimer. 2) Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following
 * disclaimer in the documentation and/or other materials provided
 * with the distribution. 3) Neither the name of the jcabi.com nor
 * the names of its contributors may be used to endorse or promote
 * products derived from this software without specific prior written
 * permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT
 * NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
 * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
 * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
 * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
 * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
 * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
 * OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package com.jcabi.aspects.aj;

import com.jcabi.aspects.Cacheable;
import com.jcabi.aspects.Loggable;
import com.jcabi.log.Logger;
import java.lang.ref.SoftReference;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicInteger;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;

/**
 * Cache method results.
 *
 * <p>It is an AspectJ aspect and you are not supposed to use it directly. It
 * is instantiated by AspectJ runtime framework when your code is annotated
 * with {@link Cacheable} annotation.
 *
 * <p>The class is thread-safe.
 *
 * @author Yegor Bugayenko (yegor@tpc2.com)
 * @version $Id: 81af9dfb282bbcda540b9148c81ef09cf1cd0199 $
 * @since 0.8
 */
@Aspect
@SuppressWarnings("PMD.TooManyMethods")
public final class MethodCacher {

    /**
     * Calling tunnels.
     */
    private final transient
        ConcurrentMap<MethodCacher.Key, MethodCacher.Tunnel> tunnels;

    /**
     * Save the keys of caches which need update.
     */
    private final transient BlockingQueue<Key> updatekeys;

    /**
     * Public ctor.
     */
    public MethodCacher() {
        this.tunnels =
            new ConcurrentHashMap<MethodCacher.Key, MethodCacher.Tunnel>(0);
        this.updatekeys =
            new LinkedBlockingQueue<MethodCacher.Key>();
        new UpdateMethodCacher(this.tunnels, this.updatekeys).start();
    }

    /**
     * Call the method or fetch from cache.
     *
     * <p>Try NOT to change the signature of this method, in order to keep
     * it backward compatible.
     *
     * @param point Joint point
     * @return The result of call
     * @throws Throwable If something goes wrong inside
     * @checkstyle IllegalThrows (4 lines)
     */
    @Around("execution(* *(..)) && @annotation(com.jcabi.aspects.Cacheable)")
    public Object cache(final ProceedingJoinPoint point) throws Throwable {
        final MethodCacher.Key key = new MethodCacher.Key(point);
        MethodCacher.Tunnel tunnel;
        final Method method = MethodSignature.class
            .cast(point.getSignature())
            .getMethod();
        final Cacheable annot = method.getAnnotation(Cacheable.class);
        synchronized (this.tunnels) {
            for (final Class<?> before : annot.before()) {
                final boolean flag = Boolean.class.cast(
                    before.getMethod("flushBefore").invoke(method.getClass())
                );
                if (flag) {
                    this.preflush(point);
                }
            }
            tunnel = this.tunnels.get(key);
            if (this.isCreateTunnel(tunnel)) {
                tunnel = new MethodCacher.Tunnel(
                    point, key, annot.asyncUpdate()
                );
                this.tunnels.put(key, tunnel);
            }
            if (tunnel.expired() && tunnel.asyncUpdate()) {
                this.updatekeys.offer(key);
            }
            for (final Class<?> after : annot.after()) {
                final boolean flag = Boolean.class.cast(
                    after.getMethod("flushAfter").invoke(method.getClass())
                );
                if (flag) {
                    this.postflush(point);
                }
            }
        }
        return tunnel.through();
    }

    /**
     * Flush cache.
     * @param point Join point
     * @return Value of the method
     * @since 0.7.14
     * @deprecated Since 0.7.17, and preflush() should be used
     * @throws Throwable If something goes wrong inside
     * @checkstyle IllegalThrows (3 lines)
     */
    @Deprecated
    public Object flush(final ProceedingJoinPoint point) throws Throwable {
        this.preflush(point);
        return point.proceed();
    }

    /**
     * Flush cache.
     *
     * <p>Try NOT to change the signature of this method, in order to keep
     * it backward compatible.
     *
     * @param point Joint point
     * @since 0.7.14
     */
    @Before
        (
            // @checkstyle StringLiteralsConcatenation (3 lines)
            "execution(* *(..))"
            + " && (@annotation(com.jcabi.aspects.Cacheable.Flush)"
            + " || @annotation(com.jcabi.aspects.Cacheable.FlushBefore))"
        )
    public void preflush(final JoinPoint point) {
        this.flush(point, "before the call");
    }

    /**
     * Flush cache after method execution.
     *
     * <p>Try NOT to change the signature of this method, in order to keep
     * it backward compatible.
     *
     * @param point Joint point
     * @since 0.7.18
     */
    @After
        (
            // @checkstyle StringLiteralsConcatenation (2 lines)
            "execution(* *(..))"
            + " && @annotation(com.jcabi.aspects.Cacheable.FlushAfter)"
        )
    public void postflush(final JoinPoint point) {
        this.flush(point, "after the call");
    }

    /**
     * Flush cache.
     * @param point Joint point
     * @param when When it happens
     * @since 0.7.18
     */
    private void flush(final JoinPoint point, final String when) {
        synchronized (this.tunnels) {
            for (final MethodCacher.Key key : this.tunnels.keySet()) {
                if (!key.sameTarget(point)) {
                    continue;
                }
                final MethodCacher.Tunnel removed = this.tunnels.remove(key);
                final Method method = MethodSignature.class
                    .cast(point.getSignature())
                    .getMethod();
                if (LogHelper.enabled(
                    key.getLevel(), method.getDeclaringClass()
                )) {
                    LogHelper.log(
                        key.getLevel(),
                        method.getDeclaringClass(),
                        "%s: %s:%s removed from cache %s",
                        Mnemos.toText(method, point.getArgs(), true, false),
                        key,
                        removed,
                        when
                    );
                }
            }
        }
    }

    /**
     * Whether create a new Tunnel.
     * @param tunnel MethodCacher.Tunnel
     * @return Boolean
     */
    private boolean isCreateTunnel(final MethodCacher.Tunnel tunnel) {
        return tunnel == null || (tunnel.expired() && !tunnel.asyncUpdate());
    }

    /**
     * Mutable caching/calling tunnel, it is thread-safe.
     */
    protected static final class Tunnel {
        /**
         * Proceeding join point.
         */
        private final transient ProceedingJoinPoint point;
        /**
         * Key related to this tunnel.
         */
        private final transient MethodCacher.Key key;
        /**
         * Whether asynchronous update.
         */
        private final transient boolean async;
        /**
         * Was it already executed?
         */
        private transient boolean executed;
        /**
         * When will it expire (moment in time).
         */
        private transient long lifetime;
        /**
         * Has non-null result?
         */
        private transient boolean hasresult;
        /**
         * Cached value.
         */
        @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName")
        private transient SoftReference<Object> cached;

        /**
         * Public ctor.
         * @param pnt ProceedingJoinPoint
         * @param akey MethodCacher.Key
         * @param asy Boolean
         */
        Tunnel(final ProceedingJoinPoint pnt,
            final MethodCacher.Key akey, final boolean asy) {
            this.point = pnt;
            this.key = akey;
            this.async = asy;
        }

        @Override
        public String toString() {
            return Mnemos.toText(this.cached.get(), true, false);
        }

        /**
         * Get a new instance.
         * @return MethodCacher.Tunnel
         */
        public Tunnel copy() {
            return new Tunnel(
                this.point, this.key, this.async
            );
        }
        /**
         * Get a result through the tunnel.
         * @return The result
         * @throws Throwable If something goes wrong inside
         * @checkstyle IllegalThrows (5 lines)
         */
        @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel")
        public synchronized Object through() throws Throwable {
            if (!this.executed) {
                final long start = System.currentTimeMillis();
                final Object result = this.point.proceed();
                this.hasresult = result != null;
                this.cached = new SoftReference<Object>(result);
                final Method method = MethodSignature.class
                    .cast(this.point.getSignature())
                    .getMethod();
                final Cacheable annot = method.getAnnotation(Cacheable.class);
                final String suffix;
                if (annot.forever()) {
                    this.lifetime = Long.MAX_VALUE;
                    suffix = "valid forever";
                } else if (annot.lifetime() == 0) {
                    this.lifetime = 0L;
                    suffix = "invalid immediately";
                } else {
                    final long msec = annot.unit().toMillis(
                        (long) annot.lifetime()
                    );
                    this.lifetime = start + msec;
                    suffix = Logger.format("valid for %[ms]s", msec);
                }
                final Class<?> type = method.getDeclaringClass();
                if (LogHelper.enabled(this.key.getLevel(), type)) {
                    LogHelper.log(
                        this.key.getLevel(),
                        type,
                        "%s: %s cached in %[ms]s, %s",
                        Mnemos.toText(
                            method, this.point.getArgs(), true, false
                        ),
                        Mnemos.toText(this.cached.get(), true, false),
                        System.currentTimeMillis() - start,
                        suffix
                    );
                }
                this.executed = true;
            }
            return this.key.through(this.cached.get());
        }
        /**
         * Is it expired already?
         * @return TRUE if expired
         */
        public boolean expired() {
            final boolean expired = this.lifetime < System.currentTimeMillis();
            final boolean collected = this.executed
                    && this.hasresult
                    && this.cached.get() == null;
            return this.executed && (expired || collected);
        }

        /**
         * Whether asynchronous update.
         * @return TRUE if asynchronous update
         */
        public boolean asyncUpdate() {
            return this.async;
        }

        /**
         * Soft reference to cached object.
         * Visible only for testing. Do not use directly.
         * @return Soft reference to cached object.
         */
        @SuppressWarnings("PMD.DefaultPackage")
        SoftReference<Object> cached() {
            return this.cached;
        }
    }

    /**
     * Key of a callable target.
     * @checkstyle DesignForExtensionCheck (2 lines)
     */
    protected static class Key {
        /**
         * When instantiated.
         */
        private final transient long start;
        /**
         * How many times the key was already accessed.
         */
        private final transient AtomicInteger accessed;
        /**
         * Method.
         */
        private final transient Method method;
        /**
         * Object callable (or class, if static method).
         */
        private final transient Object target;
        /**
         * Arguments.
         */
        private final transient Object[] arguments;

        /**
         * Log level.
         */
        private final int level;

        /**
         * Public ctor.
         * @param point Joint point
         */
        Key(final JoinPoint point) {
            this.start = System.currentTimeMillis();
            this.accessed = new AtomicInteger();
            this.method = MethodSignature.class
                .cast(point.getSignature()).getMethod();
            this.target = MethodCacher.Key.targetize(point);
            this.arguments = point.getArgs();
            if (this.method.isAnnotationPresent(Loggable.class)) {
                this.level = this.method.getAnnotation(Loggable.class).value();
            } else {
                this.level = Loggable.DEBUG;
            }
        }

        /**
         * Get log level.
         * @return Log level of current method.
         */
        public final int getLevel() {
            return this.level;
        }

        @Override
        public final String toString() {
            return Mnemos.toText(this.method, this.arguments, true, false);
        }
        @Override
        public final int hashCode() {
            return this.method.hashCode();
        }
        @Override
        public final boolean equals(final Object obj) {
            final boolean equals;
            if (this == obj) {
                equals = true;
            } else if (obj instanceof MethodCacher.Key) {
                final MethodCacher.Key key = MethodCacher.Key.class.cast(obj);
                equals = key.method.equals(this.method)
                    && this.target.equals(key.target)
                    && Arrays.deepEquals(key.arguments, this.arguments);
            } else {
                equals = false;
            }
            return equals;
        }
        /**
         * Send a result through, with necessary logging.
         * @param result The result to send through
         * @return The same result/object
         * @checkstyle DesignForExtensionCheck (2 lines)
         */
        public Object through(final Object result) {
            final int hit = this.accessed.getAndIncrement();
            final Class<?> type = this.method.getDeclaringClass();
            if (hit > 0 && LogHelper.enabled(this.level, type)) {
                LogHelper.log(
                    this.level,
                    type,
                    "%s: %s from cache (hit #%d, %[ms]s old)",
                    this,
                    Mnemos.toText(result, true, false),
                    hit,
                    System.currentTimeMillis() - this.start
                );
            }
            return result;
        }
        /**
         * Is it related to the same target?
         * @param point Proceeding point
         * @return True if the target is the same
         */
        public final boolean sameTarget(final JoinPoint point) {
            return MethodCacher.Key.targetize(point).equals(this.target);
        }
        /**
         * Calculate its target.
         * @param point Proceeding point
         * @return The target
         */
        private static Object targetize(final JoinPoint point) {
            final Object tgt;
            final Method method = MethodSignature.class
                .cast(point.getSignature()).getMethod();
            if (Modifier.isStatic(method.getModifiers())) {
                tgt = method.getDeclaringClass();
            } else {
                tgt = point.getTarget();
            }
            return tgt;
        }

    }
}
