package me.ramswaroop.jbot.core.facebook;

import me.ramswaroop.jbot.core.common.BaseBot;
import me.ramswaroop.jbot.core.common.Controller;
import me.ramswaroop.jbot.core.common.EventType;
import me.ramswaroop.jbot.core.facebook.models.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

import javax.annotation.PostConstruct;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Queue;
import java.util.regex.Matcher;

/**
 * @author ramswaroop
 * @version 11/09/2016
 */
public abstract class Bot extends BaseBot {

    private static final Logger logger = LoggerFactory.getLogger(Bot.class);

    private String fbSendUrl;
    private String fbMessengerProfileUrl;

    @Autowired
    protected RestTemplate restTemplate;

    @Autowired
    protected FbApiEndpoints fbApiEndpoints;

    @PostConstruct
    private void constructFbSendUrl() {
        fbSendUrl = fbApiEndpoints.getFbSendUrl().replace("{PAGE_ACCESS_TOKEN}", getPageAccessToken());
        fbMessengerProfileUrl = fbApiEndpoints.getFbMessengerProfileUrl().replace("{PAGE_ACCESS_TOKEN}",
                getPageAccessToken());
    }

    /**
     * Class extending this must implement this as it's
     * required to setup the webhook.
     *
     * @return facebook token
     */
    public abstract String getFbToken();

    /**
     * Class extending this must implement this as it's
     * required for Send API.
     *
     * @return facebook page access token
     */
    public abstract String getPageAccessToken();

    /**
     * @param mode
     * @param verifyToken
     * @param challenge
     * @return if verify token is valid then 200 OK with challenge in the body or else forbidden error
     */
    @GetMapping("/webhook")
    public final ResponseEntity setupWebhookVerification(@RequestParam("hub.mode") String mode,
                                                         @RequestParam("hub.verify_token") String verifyToken,
                                                         @RequestParam("hub.challenge") String challenge) {
        if (EventType.SUBSCRIBE.name().equalsIgnoreCase(mode) && getFbToken().equals(verifyToken)) {
            return ResponseEntity.ok(challenge);
        } else {
            return new ResponseEntity<>(HttpStatus.FORBIDDEN);
        }
    }

    /**
     * Add webhook endpoint
     *
     * @param callback
     * @return 200 OK response
     */
    @ResponseBody
    @PostMapping("/webhook")
    public final ResponseEntity setupWebhookEndpoint(@RequestBody Callback callback) {
        try {
            // Checks this is an event from a page subscription
            if (!callback.getObject().equals("page")) {
                return new ResponseEntity<>(HttpStatus.NOT_FOUND);
            }
            logger.debug("Callback from fb: {}", callback);
            for (Entry entry : callback.getEntry()) {
                if (entry.getMessaging() != null) {
                    for (Event event : entry.getMessaging()) {
                        if (event.getMessage() != null) {
                            if (event.getMessage().isEcho() != null &&
                                    event.getMessage().isEcho()) {
                                event.setType(EventType.MESSAGE_ECHO);
                            } else if (event.getMessage().getQuickReply() != null) {
                                event.setType(EventType.QUICK_REPLY);
                            } else {
                                event.setType(EventType.MESSAGE);
                                // send typing on indicator to create a conversational experience
                                sendTypingOnIndicator(event.getSender());
                            }
                        } else if (event.getDelivery() != null) {
                            event.setType(EventType.MESSAGE_DELIVERED);
                        } else if (event.getRead() != null) {
                            event.setType(EventType.MESSAGE_READ);
                        } else if (event.getPostback() != null) {
                            event.setType(EventType.POSTBACK);
                        } else if (event.getOptin() != null) {
                            event.setType(EventType.OPT_IN);
                        } else if (event.getReferral() != null) {
                            event.setType(EventType.REFERRAL);
                        } else if (event.getAccountLinking() != null) {
                            event.setType(EventType.ACCOUNT_LINKING);
                        } else {
                            logger.debug("Callback/Event type not supported: {}", event);
                            return ResponseEntity.ok("Callback not supported yet!");
                        }
                        if (isConversationOn(event)) {
                            invokeChainedMethod(event);
                        } else {
                            invokeMethods(event);
                        }
                    }
                }
            }
        } catch (Exception e) {
            logger.error("Error in fb webhook: Callback: {} \nException: ", callback.toString(), e);
        }
        // fb advises to send a 200 response within 20 secs
        return ResponseEntity.ok("EVENT_RECEIVED");
    }

    private void sendTypingOnIndicator(User recipient) {
        restTemplate.postForEntity(fbSendUrl,
                new Event().setRecipient(recipient).setSenderAction("typing_on"), Response.class);
    }

    private void sendTypingOffIndicator(User recipient) {
        restTemplate.postForEntity(fbSendUrl,
                new Event().setRecipient(recipient).setSenderAction("typing_off"), Response.class);
    }

    protected final ResponseEntity<String> reply(Event event) {
        sendTypingOffIndicator(event.getRecipient());
        logger.debug("Send message: {}", event.toString());
        try {
            return restTemplate.postForEntity(fbSendUrl, event, String.class);
        } catch (HttpClientErrorException e) {
            logger.error("Send message error: Response body: {} \nException: ", e.getResponseBodyAsString(), e);
            return new ResponseEntity<>(e.getResponseBodyAsString(), e.getStatusCode());
        }
    }

    protected ResponseEntity<String> reply(Event event, String text) {
        Event response = new Event()
                .setMessagingType("RESPONSE")
                .setRecipient(event.getSender())
                .setMessage(new Message().setText(text));
        return reply(response);
    }

    protected ResponseEntity<String> reply(Event event, Message message) {
        Event response = new Event()
                .setMessagingType("RESPONSE")
                .setRecipient(event.getSender())
                .setMessage(message);
        return reply(response);
    }

    /**
     * Call this method with a {@code payload} to set the "Get Started" button. A user sees this button
     * when it first starts a conversation with the bot.
     * <p>
     * See https://developers.facebook.com/docs/messenger-platform/discovery/welcome-screen for more.
     *
     * @param payload for "Get Started" button
     * @return response from facebook
     */
    protected final ResponseEntity<Response> setGetStartedButton(String payload) {
        Event event = new Event().setGetStarted(new Postback().setPayload(payload));
        return restTemplate.postForEntity(fbMessengerProfileUrl, event, Response.class);
    }

    /**
     * Call this method to set the "Greeting Text". A user sees this when it opens up the chat window for the
     * first time. You can specify different messages for different locales. Therefore, this method receives an
     * array of {@code greeting}.
     * <p>
     * See https://developers.facebook.com/docs/messenger-platform/discovery/welcome-screen for more.
     *
     * @param greeting an array of Payload consisting of text and locale
     * @return response from facebook
     */
    protected final ResponseEntity<Response> setGreetingText(Payload[] greeting) {
        Event event = new Event().setGreeting(greeting);
        return restTemplate.postForEntity(fbMessengerProfileUrl, event, Response.class);
    }

    /**
     * Invoke this method to make the bot subscribe to a page after which
     * your users can interact with your page or in other words, the bot.
     * <p>
     * NOTE: It seems Fb now allows the bot to subscribe to a page via the
     * ui. See https://developers.facebook.com/docs/messenger-platform/getting-started/app-setup
     */
    @PostMapping("/subscribe")
    public final void subscribeAppToPage() {
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.set("access_token", getPageAccessToken());
        restTemplate.postForEntity(fbApiEndpoints.getSubscribeUrl(), params, String.class);
    }

    /**
     * Call this method to start a conversation.
     *
     * @param event received from facebook
     */
    protected final void startConversation(Event event, String methodName) {
        startConversation(event.getSender().getId(), methodName);
    }

    /**
     * Call this method to jump to the next method in a conversation.
     *
     * @param event received from facebook
     */
    protected final void nextConversation(Event event) {
        nextConversation(event.getSender().getId());
    }

    /**
     * Call this method to stop the end the conversation.
     *
     * @param event received from facebook
     */
    protected final void stopConversation(Event event) {
        stopConversation(event.getSender().getId());
    }

    /**
     * Check whether a conversation is up in a particular slack channel.
     *
     * @param event received from facebook
     * @return true if a conversation is on, false otherwise.
     */
    protected final boolean isConversationOn(Event event) {
        return isConversationOn(event.getSender().getId());
    }

    /**
     * Invoke the methods with matching {@link Controller#events()}
     * and {@link Controller#pattern()} in events received from Slack/Facebook.
     *
     * @param event received from facebook
     */
    private void invokeMethods(Event event) {
        try {
            List<MethodWrapper> methodWrappers = eventToMethodsMap.get(event.getType().name().toUpperCase());
            if (methodWrappers == null) return;

            methodWrappers = new ArrayList<>(methodWrappers);
            MethodWrapper matchedMethod =
                    getMethodWithMatchingPatternAndFilterUnmatchedMethods(getPatternFromEventType(event), methodWrappers);
            if (matchedMethod != null) {
                methodWrappers = new ArrayList<>();
                methodWrappers.add(matchedMethod);
            }

            for (MethodWrapper methodWrapper : methodWrappers) {
                Method method = methodWrapper.getMethod();
                if (Arrays.asList(method.getParameterTypes()).contains(Matcher.class)) {
                    method.invoke(this, event, methodWrapper.getMatcher());
                } else {
                    method.invoke(this, event);
                }
            }
        } catch (Exception e) {
            logger.error("Error invoking controller: ", e);
        }
    }

    /**
     * Invoke the appropriate method in a conversation.
     *
     * @param event received from facebook
     */
    private void invokeChainedMethod(Event event) {
        Queue<MethodWrapper> queue = conversationQueueMap.get(event.getSender().getId());

        if (queue != null && !queue.isEmpty()) {
            MethodWrapper methodWrapper = queue.peek();

            try {
                EventType[] eventTypes = methodWrapper.getMethod().getAnnotation(Controller.class).events();
                for (EventType eventType : eventTypes) {
                    if (eventType.name().equalsIgnoreCase(event.getType().name())) {
                        methodWrapper.getMethod().invoke(this, event);
                        return;
                    }
                }
            } catch (Exception e) {
                logger.error("Error invoking chained method: ", e);
            }
        }
    }

    /**
     * Match the pattern with different attributes based on the event type.
     *
     * @param event received from facebook
     * @return the pattern string
     */
    private String getPatternFromEventType(Event event) {
        switch (event.getType()) {
            case MESSAGE:
                return event.getMessage().getText();
            case QUICK_REPLY:
                return event.getMessage().getQuickReply().getPayload();
            case POSTBACK:
                return event.getPostback().getPayload();
            default:
                return event.getMessage().getText();
        }
    }
}