/*
 * Copyright 2016 LINE Corporation
 *
 * LINE Corporation licenses this file to you under the Apache License,
 * version 2.0 (the "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */

package com.linecorp.bot.spring.boot.support;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Import;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;

import com.linecorp.bot.model.event.Event;
import com.linecorp.bot.model.event.MessageEvent;
import com.linecorp.bot.model.event.ReplyEvent;
import com.linecorp.bot.model.event.message.MessageContent;
import com.linecorp.bot.spring.boot.annotation.EventMapping;
import com.linecorp.bot.spring.boot.annotation.LineBotMessages;
import com.linecorp.bot.spring.boot.annotation.LineMessageHandler;

import lombok.Value;
import lombok.extern.slf4j.Slf4j;

/**
 * Dispatcher for LINE Message Event Handling.
 *
 * <p>Dispatch target method is collected by Spring's bean.
 *
 * <p>Event endpoint is configurable by {@code line.bot.callback-path} parameter.
 *
 * <h2>Event Handler method rule.</h2>
 *
 * <p>The class and method with following rules are collected by LINE Messaging Event handler.
 *
 * <ul>
 * <li>Class annotated with {@link LineMessageHandler}</li>
 * <li>Method annotated with {@link EventMapping}.</li>
 * </ul>
 */
@Slf4j
@RestController
@Import(ReplyByReturnValueConsumer.Factory.class)
@ConditionalOnProperty(name = "line.bot.handler.enabled", havingValue = "true", matchIfMissing = true)
public class LineMessageHandlerSupport {
    private static final Comparator<HandlerMethod> HANDLER_METHOD_PRIORITY_COMPARATOR =
            Comparator.comparing(HandlerMethod::getPriority).reversed();
    private final ReplyByReturnValueConsumer.Factory returnValueConsumerFactory;
    private final ConfigurableApplicationContext applicationContext;

    volatile List<HandlerMethod> eventConsumerList;

    @Autowired
    public LineMessageHandlerSupport(
            final ReplyByReturnValueConsumer.Factory returnValueConsumerFactory,
            final ConfigurableApplicationContext applicationContext) {
        this.returnValueConsumerFactory = returnValueConsumerFactory;
        this.applicationContext = applicationContext;

        applicationContext.addApplicationListener(event -> {
            if (event instanceof ContextRefreshedEvent) {
                refresh();
            }
        });
    }

    @VisibleForTesting
    void refresh() {
        final Map<String, Object> handlerBeanMap =
                applicationContext.getBeansWithAnnotation(LineMessageHandler.class);

        final List<HandlerMethod> collect = handlerBeanMap
                .values().stream()
                .flatMap((Object bean) -> {
                    final Method[] uniqueDeclaredMethods =
                            ReflectionUtils.getUniqueDeclaredMethods(bean.getClass());

                    return Arrays.stream(uniqueDeclaredMethods)
                                 .map(method -> getMethodHandlerMethodFunction(bean, method))
                                 .filter(Objects::nonNull);
                })
                .sorted(HANDLER_METHOD_PRIORITY_COMPARATOR)
                .collect(Collectors.toList());

        log.info("Registered LINE Messaging API event handler: count = {}", collect.size());
        collect.forEach(item -> log.info("Mapped \"{}\" onto {}",
                                         item.getSupportType(), item.getHandler().toGenericString()));

        eventConsumerList = collect;
    }

    private HandlerMethod getMethodHandlerMethodFunction(Object consumer, Method method) {
        final EventMapping mapping = AnnotatedElementUtils.getMergedAnnotation(method, EventMapping.class);
        if (mapping == null) {
            return null;
        }

        Preconditions.checkState(method.getParameterCount() == 1,
                                 "Number of parameter should be 1. But {}",
                                 (Object[]) method.getParameterTypes());
        // TODO: Support more than 1 argument. Like MVC's argument resolver?

        final Type type = method.getGenericParameterTypes()[0];

        final Predicate<Event> predicate = new EventPredicate(type);
        return new HandlerMethod(predicate, consumer, method, getPriority(mapping, type));
    }

    private int getPriority(final EventMapping mapping, final Type type) {
        if (mapping.priority() != EventMapping.DEFAULT_PRIORITY_VALUE) {
            return mapping.priority();
        }

        if (type == Event.class) {
            return EventMapping.DEFAULT_PRIORITY_FOR_EVENT_IFACE;
        }

        if (type instanceof Class) {
            return ((Class<?>) type).isInterface()
                   ? EventMapping.DEFAULT_PRIORITY_FOR_IFACE
                   : EventMapping.DEFAULT_PRIORITY_FOR_CLASS;
        }

        if (type instanceof ParameterizedType) {
            return EventMapping.DEFAULT_PRIORITY_FOR_PARAMETRIZED_TYPE;
        }

        throw new IllegalStateException();
    }

    @Value
    static class HandlerMethod {
        Predicate<Event> supportType;
        Object object;
        Method handler;
        int priority;
    }

    @PostMapping("${line.bot.handler.path:/callback}")
    public void callback(@LineBotMessages List<Event> events) {
        events.forEach(this::dispatch);
    }

    @VisibleForTesting
    void dispatch(Event event) {
        try {
            dispatchInternal(event);
        } catch (InvocationTargetException e) {
            log.error("InvocationTargetException occurred.", e);
        } catch (Error | Exception e) {
            log.error(e.getMessage(), e);
        }
    }

    private void dispatchInternal(final Event event) throws Exception {
        final HandlerMethod handlerMethod = eventConsumerList
                .stream()
                .filter(consumer -> consumer.getSupportType().test(event))
                .findFirst()
                .orElseThrow(() -> new UnsupportedOperationException("Unsupported event type. " + event));
        final Object returnValue = handlerMethod.getHandler().invoke(handlerMethod.getObject(), event);

        handleReturnValue(event, returnValue);
    }

    private void handleReturnValue(final Event event, final Object returnValue) {
        if (returnValue != null) {
            returnValueConsumerFactory.createForEvent(event)
                                      .accept(returnValue);
        }
    }

    private static class EventPredicate implements Predicate<Event> {
        private final Class<?> supportEvent;
        private final Class<? extends MessageContent> messageContentType;

        @SuppressWarnings("unchecked")
        EventPredicate(final Type mapping) {
            if (mapping == ReplyEvent.class) {
                supportEvent = ReplyEvent.class;
                messageContentType = null;
            } else if (mapping instanceof Class) {
                Preconditions.checkState(Event.class.isAssignableFrom((Class<?>) mapping),
                                         "Handler argument type should BE-A Event. But {}",
                                         mapping.getClass());
                supportEvent = (Class<? extends Event>) mapping;
                messageContentType = null;
            } else {
                final ParameterizedType parameterizedType = (ParameterizedType) mapping;
                supportEvent = (Class<? extends Event>) parameterizedType.getRawType();
                messageContentType =
                        (Class<? extends MessageContent>)
                                ((ParameterizedType) mapping).getActualTypeArguments()[0];
            }
        }

        @Override
        public boolean test(final Event event) {
            return supportEvent.isAssignableFrom(event.getClass())
                   && (messageContentType == null
                       || event instanceof MessageEvent
                          && filterByType(messageContentType, ((MessageEvent<?>) event).getMessage()));
        }

        private static boolean filterByType(final Class<?> clazz, final Object content) {
            return clazz.isAssignableFrom(content.getClass());
        }

        @Override
        public String toString() {
            final StringBuilder sb = new StringBuilder();

            sb.append('[');
            if (messageContentType != null) {
                sb.append(MessageEvent.class.getSimpleName())
                  .append('<')
                  .append(messageContentType.getSimpleName())
                  .append('>');
            } else {
                sb.append(supportEvent.getSimpleName());
            }
            sb.append(']');

            return sb.toString();
        }
    }
}