/**
 * 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.Loggable;
import com.jcabi.log.Logger;
import com.jcabi.log.VerboseRunnable;
import java.lang.reflect.Method;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.Executors;
import java.util.concurrent.FutureTask;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;

/**
 * Logs method calls.
 *
 * <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 Loggable} annotation.
 *
 * @author Yegor Bugayenko (yegor@tpc2.com)
 * @version $Id: 75ee92b1645b4e6b0a06b21951bf2d3846430588 $
 * @since 0.7.2
 * @checkstyle IllegalThrows (500 lines)
 */
@Aspect
@SuppressWarnings
    (
        {
            "PMD.AvoidCatchingThrowable",
            "PMD.TooManyMethods",
            "PMD.CyclomaticComplexity"
        }
    )
public final class MethodLogger {

    /**
     * Currently running methods.
     */
    private final transient Set<Marker> running;

    /**
     * Public ctor.
     */
    @SuppressWarnings("PMD.DoNotUseThreads")
    public MethodLogger() {
        this.running = new ConcurrentSkipListSet<Marker>();
        final ScheduledExecutorService monitor =
            Executors.newSingleThreadScheduledExecutor(
                new NamedThreads(
                    "loggable",
                    "watching of @Loggable annotated methods"
                )
            );
        monitor.scheduleWithFixedDelay(
            new FutureTask<Void>(
                new VerboseRunnable(
                    new Runnable() {
                        @Override
                        public void run() {
                            for (final MethodLogger.Marker marker
                                : MethodLogger.this.running) {
                                marker.monitor();
                            }
                        }
                    }
                ), null
            ) {
                @Override
                protected void done() {
                    Logger.debug(this, "Logging monitor thread interrupted");
                    monitor.shutdown();
                }
            },
            1, 1, TimeUnit.SECONDS
        );
    }

    /**
     * Log methods in a class.
     *
     * <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
     */
    @Around
        (
            // @checkstyle StringLiteralsConcatenation (7 lines)
            "execution(public * (@com.jcabi.aspects.Loggable *).*(..))"
            + " && !execution(String *.toString())"
            + " && !execution(int *.hashCode())"
            + " && !execution(boolean *.canEqual(Object))"
            + " && !execution(boolean *.equals(Object))"
            + " && !cflow(call(com.jcabi.aspects.aj.MethodLogger.new()))"
        )
    public Object wrapClass(final ProceedingJoinPoint point) throws Throwable {
        final Method method =
            MethodSignature.class.cast(point.getSignature()).getMethod();
        Object output;
        if (method.isAnnotationPresent(Loggable.class)) {
            output = point.proceed();
        } else {
            output = this.wrap(
                point,
                method,
                method.getDeclaringClass().getAnnotation(Loggable.class)
            );
        }
        return output;
    }

    /**
     * Log individual methods.
     *
     * <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
     */
    @Around
        (
            // @checkstyle StringLiteralsConcatenation (2 lines)
            "(execution(* *(..)) || initialization(*.new(..)))"
            + " && @annotation(com.jcabi.aspects.Loggable)"
        )
    @SuppressWarnings("PMD.AvoidCatchingThrowable")
    public Object wrapMethod(final ProceedingJoinPoint point) throws Throwable {
        final Method method =
            MethodSignature.class.cast(point.getSignature()).getMethod();
        return this.wrap(point, method, method.getAnnotation(Loggable.class));
    }

    /**
     * Catch exception and re-call the method.
     * @param point Joint point
     * @param method The method
     * @param annotation The annotation
     * @return The result of call
     * @throws Throwable If something goes wrong inside
     * @checkstyle ExecutableStatementCountCheck (100 lines)
     */
    private Object wrap(final ProceedingJoinPoint point, final Method method,
        final Loggable annotation) throws Throwable {
        if (Thread.interrupted()) {
            throw new IllegalStateException(
                String.format(
                    "thread '%s' in group '%s' interrupted",
                    Thread.currentThread().getName(),
                    Thread.currentThread().getThreadGroup().getName()
                )
            );
        }
        final long start = System.nanoTime();
        final MethodLogger.Marker marker =
            new MethodLogger.Marker(point, annotation);
        this.running.add(marker);
        int level = annotation.value();
        try {
            final Object logger = this.logger(method, annotation.name());
            if (annotation.prepend()) {
                LogHelper.log(
                    level,
                    logger,
                    new StringBuilder(
                        Mnemos.toText(
                            point,
                            annotation.trim(),
                            annotation.skipArgs(),
                            annotation.logThis()
                        )
                    ).append(": entered").toString()
                );
            }
            final Object result = point.proceed();
            final long nano = System.nanoTime() - start;
            if (LogHelper.enabled(level, logger)
                || this.over(annotation, nano)) {
                if (this.over(annotation, nano)) {
                    level = Loggable.WARN;
                }
                LogHelper.log(
                    level, logger,
                    this.message(point, method, annotation, result, nano)
                );
            }
            return result;
        // @checkstyle IllegalCatch (1 line)
        } catch (final Throwable ex) {
            if (!MethodLogger.contains(annotation.ignore(), ex)
                && !ex.getClass().isAnnotationPresent(Loggable.Quiet.class)) {
                final StackTraceElement[] traces = ex.getStackTrace();
                final String origin;
                if (traces.length > 0) {
                    final StackTraceElement trace = traces[0];
                    origin = MethodLogger.oneText(trace);
                } else {
                    origin = "somewhere";
                }
                LogHelper.log(
                    level,
                    method.getDeclaringClass(),
                    Logger.format(
                        "%s: thrown %s out of %s in %[nano]s",
                        Mnemos.toText(
                            point,
                            annotation.trim(),
                            annotation.skipArgs(),
                            annotation.logThis()
                        ),
                        Mnemos.toText(ex),
                        origin,
                        System.nanoTime() - start
                    )
                );
            }
            throw ex;
        } finally {
            this.running.remove(marker);
        }
    }

    /**
     * Has time for method execution passed.
     * @param annotation Loggable annotation.
     * @param nano Execution time.
     * @return Is over time limit.
     */
    private boolean over(final Loggable annotation, final long nano) {
        return nano > annotation.unit().toNanos(annotation.limit());
    }

    /**
     * Prepared message for log.
     * @param point JointPoint to use.
     * @param method Method for which to log.
     * @param annotation Loggable annotation.
     * @param result Method result.
     * @param nano Method execution time.
     * @return Log message.
     * @checkstyle ParameterNumberCheck (3 lines)
     */
    private String message(final ProceedingJoinPoint point, final Method method,
        final Loggable annotation, final Object result, final long nano) {
        final StringBuilder msg = new StringBuilder();
        msg.append(
            Mnemos.toText(
                point,
                annotation.trim(),
                annotation.skipArgs(),
                annotation.logThis()
            )
        ).append(':');
        if (!method.getReturnType().equals(Void.TYPE)) {
            msg.append(' ').append(
                Mnemos.toText(
                    result,
                    annotation.trim(),
                    annotation.skipResult()
                )
            );
        }
        msg.append(
            Logger.format(
                String.format(
                    " in %%[nano].%ds", annotation.precision()
                ),
                nano
            )
        );
        if (this.over(annotation, nano)) {
            msg.append(" (too slow!)");
        }
        return msg.toString();
    }

    /**
     * Get the destination logger for this method.
     * @param method The method
     * @param name The Loggable annotation
     * @return The logger that will be used
     */
    private Object logger(final Method method, final String name) {
        final Object source;
        if (name.isEmpty()) {
            source = method.getDeclaringClass();
        } else {
            source = name;
        }
        return source;
    }

    /**
     * Checks whether array of types contains given type.
     * @param array Array of them
     * @param exp The exception to find
     * @return TRUE if it's there
     */
    private static boolean contains(final Class<? extends Throwable>[] array,
        final Throwable exp) {
        boolean contains = false;
        for (final Class<? extends Throwable> type : array) {
            if (MethodLogger.instanceOf(exp.getClass(), type)) {
                contains = true;
                break;
            }
        }
        return contains;
    }

    /**
     * The type is an instance of another type?
     * @param child The child type
     * @param parent Parent type
     * @return TRUE if child is really a child of a parent
     */
    private static boolean instanceOf(final Class<?> child,
        final Class<?> parent) {
        boolean instance = child.equals(parent)
            || (child.getSuperclass() != null
            && MethodLogger.instanceOf(child.getSuperclass(), parent));
        if (!instance) {
            for (final Class<?> iface : child.getInterfaces()) {
                instance = MethodLogger.instanceOf(iface, parent);
                if (instance) {
                    break;
                }
            }
        }
        return instance;
    }

    /**
     * Textualize a stacktrace.
     * @param trace Array of stacktrace elements
     * @return The text
     */
    private static String allText(final StackTraceElement... trace) {
        final StringBuilder text = new StringBuilder();
        for (int pos = 0; pos < trace.length; ++pos) {
            if (text.length() > 0) {
                text.append(", ");
            }
            text.append(MethodLogger.oneText(trace[pos]));
        }
        return text.toString();
    }

    /**
     * Textualize a stacktrace.
     * @param trace One stacktrace element
     * @return The text
     */
    private static String oneText(final StackTraceElement trace) {
        return String.format(
            "%s#%s[%d]",
            trace.getClassName(),
            trace.getMethodName(),
            trace.getLineNumber()
        );
    }

    /**
     * Marker of a running method.
     */
    private static final class Marker
        implements Comparable<MethodLogger.Marker> {
        /**
         * When the method was started, in milliseconds.
         */
        private final transient long started;
        /**
         * Which monitoring cycle was logged recently.
         */
        private final transient AtomicInteger logged;
        /**
         * The thread it's running in.
         */
        @SuppressWarnings("PMD.DoNotUseThreads")
        private final transient Thread thread;
        /**
         * Joint point.
         */
        private final transient ProceedingJoinPoint point;
        /**
         * Annotation.
         */
        private final transient Loggable annotation;
        /**
         * Public ctor.
         * @param pnt Joint point
         * @param annt Annotation
         */
        protected Marker(final ProceedingJoinPoint pnt, final Loggable annt) {
            this.started = System.currentTimeMillis();
            this.logged = new AtomicInteger();
            this.point = pnt;
            this.annotation = annt;
            this.thread = Thread.currentThread();
        }
        /**
         * Monitor it's status and log the problem, if any.
         */
        public void monitor() {
            final TimeUnit unit = this.annotation.unit();
            final long threshold = this.annotation.limit();
            final long age = unit.convert(
                System.currentTimeMillis() - this.started, TimeUnit.MILLISECONDS
            );
            final int cycle = (int) ((age - threshold) / threshold);
            if (cycle > this.logged.get()) {
                final Method method = MethodSignature.class.cast(
                    this.point.getSignature()
                ).getMethod();
                Logger.warn(
                    method.getDeclaringClass(),
                    "%s: takes more than %[ms]s, %[ms]s already, thread=%s/%s",
                    Mnemos.toText(this.point, true, this.annotation.skipArgs()),
                    TimeUnit.MILLISECONDS.convert(threshold, unit),
                    TimeUnit.MILLISECONDS.convert(age, unit),
                    this.thread.getName(),
                    this.thread.getState()
                );
                Logger.debug(
                    method.getDeclaringClass(),
                    "%s: thread %s/%s stacktrace: %s",
                    Mnemos.toText(this.point, true, this.annotation.skipArgs()),
                    this.thread.getName(),
                    this.thread.getState(),
                    MethodLogger.allText(this.thread.getStackTrace())
                );
                this.logged.set(cycle);
            }
        }
        @Override
        public int hashCode() {
            return this.point.hashCode();
        }
        @Override
        public boolean equals(final Object obj) {
            return obj == this || MethodLogger.Marker.class.cast(obj)
                .point.equals(this.point);
        }
        @Override
        public int compareTo(final Marker marker) {
            int cmp = 0;
            if (this.started < marker.started) {
                cmp = 1;
            } else if (this.started > marker.started) {
                cmp = -1;
            }
            return cmp;
        }
    }

}
