/*
 * ICESOFT COMMERCIAL SOURCE CODE LICENSE V 1.1
 *
 * The contents of this file are subject to the ICEsoft Commercial Source
 * Code License Agreement V1.1 (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.icesoft.com/license/commercial-source-v1.1.html
 *
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
 * License for the specific language governing rights and limitations under
 * the License.
 *
 * Copyright 2009-2015 ICEsoft Technologies Canada, Corp. All Rights Reserved.
 */

package com.icesoft.icepush;

import com.amazonaws.AmazonClientException;
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.auth.DefaultAWSCredentialsProviderChain;
import com.amazonaws.services.sns.AmazonSNSAsyncClient;
import com.amazonaws.services.sns.model.*;
import org.icepush.NotificationProvider;
import org.icepush.OutOfBandNotifier;
import org.icepush.PushNotification;
import org.icepush.util.ExtensionRegistry;

import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * An implementation of a NotificationProvider that uses Amazon's SNS cloud service.  While
 * SNS supports multiple protocols our main purpose is to utilize it for SMS.  However, as
 * a service, it could provide notification support fairly easily for the multiple protocols
 * that it handles (http, https, email, sms, and Amazons own queue).
 */
public class AmazonSNSNotificationProvider implements NotificationProvider {

    private static final Logger log = Logger.getLogger(AmazonSNSNotificationProvider.class.getName());

    //Each phone number has it's own topic.  We simply preface each number with a namespace to identify it.
    public static final String TOPIC_NAMESPACE = "icesns_";

    //The key to use if the credentials are specified using ICEfaces system property or parameter
    private static final String AWS_CREDENTIALS_KEY = "org.icemobile.aws.credentials";

    //We typically jam the key and the secretKey together using this as the delimiter
    public static final String CREDENTIALS_DELIMITER = ":";

    private OutOfBandNotifier outOfBandNotifier;

    //Topics that were active during this session can be automatically deleted when the
    //the application shuts down by setting this parameter.
    private static final String DELETE_TOPICS_KEY = "org.icemobile.aws.deleteTopics";

    private AWSCredentials credentials;
    private AmazonSNSAsyncClient client;

    private static final String AWS_DEFAULT_SERVICE = "arn:aws:sns";
    private String service = AWS_DEFAULT_SERVICE;

    private static final String AWS_DEFAULT_REGION = "us-east-1";
    private String region = AWS_DEFAULT_REGION;

    private static final String AWS_DEFAULT_ACCOUNT = "983435553210";
    private String accountID = AWS_DEFAULT_ACCOUNT;

    //We maintain a list of active topics to avoid creating them if possible as well as providing
    //the ability to clean them up later.
    private List<String> activeTopics = Collections.synchronizedList(new ArrayList<String>());

    /**
     * No arg constructor which allows us to create an instance and register it without triggering
     * premature classloading.  If you use this constructor, you must at least set the credentials
     * before try to send messages.
     */
    public AmazonSNSNotificationProvider() {
        this(null);
    }

    /**
     * Constuctor that takes a valid set of credentials and uses the default values for everything else.
     *
     * @param credentials
     */
    public AmazonSNSNotificationProvider(AWSCredentials credentials) {
        this(credentials, AWS_DEFAULT_SERVICE, AWS_DEFAULT_REGION, AWS_DEFAULT_ACCOUNT);
    }

    /**
     * Constuctor that takes a valid set of credentials and values for the service, region, and account required
     * for constructing a valid Amazon Resource Name (ARN). The full ARN for an SNS topic looks like:
     * <p/>
     * arn:aws:sns:us-east-1:1234567890123456:mytopic
     * <p/>
     * Note that SMS is only provided in the us-east-1 region at the moment.
     *
     * @param credentials
     * @param service
     * @param region
     * @param account
     */
    public AmazonSNSNotificationProvider(AWSCredentials credentials, String service, String region, String account) {
        this.credentials = credentials;

        //Set up the values we need to send requests to Amazon SNS using the provided values if available
        //or falling back to defaults
        this.service = service != null ? service : AWS_DEFAULT_SERVICE;
        this.region = region != null ? region : AWS_DEFAULT_REGION;
        this.accountID = account != null ? account : AWS_DEFAULT_ACCOUNT;
    }

    /**
     * Gets a list of the currently active topics in our accountID and checks for the
     * namespace.  Each topic that is namespaced gets added to our list of active
     * topics so that we don't waste time and eneryg trying to create them again.
     */
    protected void loadActiveTopics() {
        List<Topic> currentTopics = getTopics();
        for (int i = 0; i < currentTopics.size(); i++) {
            Topic topic = currentTopics.get(i);
            String topicARN = topic.getTopicArn();
            String topicName = topicARN.substring(topicARN.lastIndexOf(":") + 1);
            if (topicName.startsWith(TOPIC_NAMESPACE)) {
                String smsNumber = topicName.substring(TOPIC_NAMESPACE.length());
                activeTopics.add(smsNumber);
            }
        }
        log.log(Level.FINE, "active topics: " + activeTopics);
    }

    //Returns the current instance of the Amazon client we are using
    private AmazonSNSAsyncClient getClient() {
        if (client == null) {

            //If the credentials are missing, we won't be able to do much
            if (credentials == null ||
                    credentials.getAWSAccessKeyId() == null ||
                    credentials.getAWSSecretKey() == null) {
                log.log(Level.SEVERE, "valid AWS credentials are required");
                return null;
            }
            client = new AmazonSNSAsyncClient(credentials);
        }
        return client;
    }

    /**
     * Publishes a message to the configured SMS topic.  We are assuming that the incoming
     * uri is in the format sms:1##########, a valid US/Cdn phone number.
     *
     * @param uri
     * @param notification
     */
    public void send(String uri, PushNotification notification) {

        //We do this lazily to avoid having to do it in the constructor in case credentials
        //have not been set yet.
        if (activeTopics == null) {
            activeTopics = Collections.synchronizedList(new ArrayList<String>());

            //Get the list of current, namespaced topics and pre-populate our internal list
            //to avoid having to create the topics and do the subscriptions for topics that
            //are already active.
            loadActiveTopics();
        }

        //Use the namespace and the SMS phone number as the topic name
        String smsNumber = uri.substring("sms:".length());

        //Check to see if it's an active topic and create it if necessary.  According to the
        //AWS JavaDoc, if the topic already exists, it will simply return the ARN without trying
        //to recreate the topic but if we can avoid the request to begin with, all the better.
        String topicARN = null;
        if (!activeTopics.contains(smsNumber)) {

            createAndSubscribeToTopic(smsNumber);
            activeTopics.add(smsNumber);
            log.log(Level.FINE, "added new active topic to list " + smsNumber);

        } else {
            //In the unlikely event that we already had an active topic that didn't have a valid
            //subscription we should do some work here to check for and add the subscription if
            //necessary.
            log.log(Level.FINE, "using active topic for " + smsNumber);
        }

        publishMessage(getTopicARN(smsNumber), notification.getSubject(), notification.getDetail());
    }

    public void registerWith(OutOfBandNotifier outOfBandNotifier) {
        outOfBandNotifier.registerProvider("sms", this);
        this.outOfBandNotifier = outOfBandNotifier;
    }

    /**
     * The AWS provider chain looks for the following values in the following order to determine
     * the credentials to use:
     * <p/>
     * Environment Variables        :  "AWS_ACCESS_KEY_ID" and "AWS_SECRET_KEY"
     * System Properties            :  "aws.accessKeyId" and "aws.secretKey"
     * EC2 Instance Metadata Service:  only applies if using that service
     * <p/>
     * If the provider chain doesn't succeed, we try:
     * ICEfaces System property  : org.icemobile.aws.credentials
     * <p/>
     * Finally, we'll just use the default value passed in.  Typically this is from a context
     * parameter with the same key as the System property above.
     * <p/>
     * In the case of ICEfaces System property and context parameter, the value is a single
     * entry delimited with a colon (e.g. "key:secretKey" ).
     */
    public boolean establishCredentials(String defaultValue) {
        DefaultAWSCredentialsProviderChain chain = new DefaultAWSCredentialsProviderChain();

        //Try the AWS credential provider chain first
        try {
            credentials = chain.getCredentials();
            return true;
        } catch (Exception e) {
            //Provider chain turned up empty so move on
        }

        //If we couldn't find the credentials using the AWSCredentialsProviderChain
        //we'll try our own System property....
        String systemPropertyValue = System.getProperty(AWS_CREDENTIALS_KEY);
        if (systemPropertyValue != null) {
            credentials = parseCredentials(systemPropertyValue);
            return true;
        }

        //As a last resort we'll try and use the default value that was passed in...
        if (defaultValue != null) {
            credentials = parseCredentials(defaultValue);
            return true;
        }

        log.log(Level.FINE, "could not establish valid AWS credentials");
        return false;
    }

    private AWSCredentials parseCredentials(String credentialData) {
        int delimiterIndex = credentialData.indexOf(CREDENTIALS_DELIMITER);
        String awsKey = credentialData.substring(0, delimiterIndex);
        String awsSecretKey = credentialData.substring(delimiterIndex + 1);
        return new BasicAWSCredentials(awsKey, awsSecretKey);
    }

    public String getService() {
        return service;
    }

    public void setService(String service) {
        this.service = service;
    }

    public String getRegion() {
        return region;
    }

    public void setRegion(String region) {
        this.region = region;
    }

    public String getAccountID() {
        return accountID;
    }

    public void setAccountID(String accountID) {
        this.accountID = accountID;
    }

    public String getNamespacedTopic(String topicName) {
        return TOPIC_NAMESPACE + topicName;
    }

    public String getTopicARN(String topicName) {
        return service + ":" + region + ":" + accountID + ":" + getNamespacedTopic(topicName);
    }

    protected List<Topic> getTopics() {
        ListTopicsResult result = getClient().listTopics();
        return result.getTopics();
    }

    protected void listTopics() {
        List topics = getTopics();
        for (int i = 0; i < topics.size(); i++) {
            Object o = topics.get(i);
        }
    }

    protected void createAndSubscribeToTopic(String smsNumber) {
        String topicName = getNamespacedTopic(smsNumber);
        try {

            //Create a topic asynchronously and get the topic ARN
            CreateTopicRequest topicReq = new CreateTopicRequest(topicName);
            AmazonSNSAsyncClient client = getClient();
            Future<CreateTopicResult> topicFuture = client.createTopicAsync(topicReq);
            CreateTopicResult topicRes = topicFuture.get(5, TimeUnit.SECONDS);
            String topicARN = topicRes.getTopicArn();
            log.log(Level.FINE, "create topic result: " + topicARN);

            //For SMS, a DisplayName attribute must be set
            SetTopicAttributesRequest attReq = new SetTopicAttributesRequest(topicARN, "DisplayName", "ICENTFY");
            client.setTopicAttributesAsync(attReq);

            //Subscribe to this new topic
            SubscribeRequest subReq = new SubscribeRequest(topicRes.getTopicArn(), "sms", smsNumber);
            Future<SubscribeResult> subFuture = client.subscribeAsync(subReq);
            //SubscribeResult subRes = subFuture.get(5, TimeUnit.SECONDS);
            //log.log(Level.INFO, "subscription pending confirmation");

        } catch (Exception e) {
            log.log(Level.SEVERE, "problem with topic creation or subscription", e);
        }
    }


    //This method is mainly used for testing as it is synchronous
    protected String createTopic(String topicName) {
        String topicARN = null;
        try {
            CreateTopicRequest req = new CreateTopicRequest(getNamespacedTopic(topicName));
            CreateTopicResult res = getClient().createTopic(req);
            topicARN = res.getTopicArn();
        } catch (AmazonClientException e) {
            log.log(Level.SEVERE, "could not create topic " + topicName, e);
        }
        return topicARN;
    }

    //This method is mainly used for testing as it is synchronous
    protected String subscribeToTopic(String arn, String smsNumber) {

        String subscriptionARN = null;

        //According to the API, valid protocols are:
        // http -- delivery of JSON-encoded message via HTTP POST
        // https -- delivery of JSON-encoded message via HTTPS POST
        // email -- delivery of message via SMTP
        // email-json -- delivery of JSON-encoded message via SMTP
        // sqs -- delivery of JSON-encoded message to an Amazon SQS queue
        // sms is supported but only currently in the US East region at this time
        try {
            SubscribeRequest req = new SubscribeRequest(arn, "sms", getNamespacedTopic(smsNumber));
            SubscribeResult result = getClient().subscribe(req);
            subscriptionARN = result.getSubscriptionArn();
        } catch (AmazonClientException e) {
            log.log(Level.SEVERE, "could not subscribe to topic " + arn, e);
        }

        return subscriptionARN;
    }

    protected void publishMessage(String arn, String subject, String message) {
        PublishRequest req = new PublishRequest(arn, subject, message);
        try {
            log.log(Level.FINE, "attempting to publish " +
                    "\n  arn    : " + arn +
                    "\n  subject: " + subject +
                    "\n  message: " + message
            );
            getClient().publish(req);
        } catch (AmazonClientException e) {
            log.log(Level.WARNING, "could not publish message", e);
        }
    }

    protected void deleteTopic(String topicName) {
        try {
            DeleteTopicRequest req = new DeleteTopicRequest(getTopicARN(topicName));
            getClient().deleteTopicAsync(req);
            log.log(Level.FINE, "deleted topic " + getTopicARN(topicName));
        } catch (AmazonClientException e) {
            log.log(Level.FINE, "could not delete topic " + getNamespacedTopic(topicName), e);
        }
    }

    protected void deleteTopics(List<String> topics) {
        synchronized (topics) {
            for (String topicName : topics) {
                try {
                    deleteTopic(topicName);
                } catch (AmazonClientException e) {
                    log.log(Level.FINE, "could not delete topic " + topicName, e);
                }
            }
        }
    }

    public void shutdown(boolean deleteTopics) {
        log.log(Level.FINE, "shutting down" + (deleteTopics ? " and deleting topics" : ""));
        if (deleteTopics) {
            deleteTopics(activeTopics);
        }
        //This gives the async calls to delete the topics a chance to finish
        getClient().getExecutorService().shutdown();
    }

    public static class AutoRegister implements ServletContextListener {

        //Private logger required to avoid instantiating the outer class and to help with NoClassDefFoundError
        private static final Logger registerLog = Logger.getLogger(AutoRegister.class.getName());

        private static final String AWS_SERVICE_KEY = "org.icemobile.aws.service";
        private static final String AWS_REGION_KEY = "org.icemobile.aws.region";
        private static final String AWS_ACCOUNT_KEY = "org.icemobile.aws.accountID";

        private AmazonSNSNotificationProvider provider;

        public void contextInitialized(ServletContextEvent servletContextEvent) {

            try {
                //Create a new provider with the provided credentials and set the general service values
                //used by the SNS service.  If they are missing, we use defaults.  This has to be done in
                //in a way that doesn't prematurely try to load the AWS classes.
                provider = new AmazonSNSNotificationProvider();

                ServletContext servletContext = servletContextEvent.getServletContext();
                boolean credentialsEstablished =
                        provider.establishCredentials(servletContext.getInitParameter(AWS_CREDENTIALS_KEY));

                if (!credentialsEstablished) {
                    throw new IllegalStateException("could not establish credentials");
                }

                String param = servletContext.getInitParameter(AWS_SERVICE_KEY);
                provider.setService(param != null ? param : AWS_DEFAULT_SERVICE);

                param = servletContext.getInitParameter(AWS_REGION_KEY);
                provider.setRegion(param != null ? param : AWS_DEFAULT_REGION);

                param = servletContext.getInitParameter(AWS_ACCOUNT_KEY);
                provider.setAccountID(param != null ? param : AWS_DEFAULT_ACCOUNT);

                ExtensionRegistry.addExtension(servletContext, 10, NotificationProvider.class.getName(),provider);
                registerLog.log(Level.INFO, "Amazon SMS Notification Provider is on: " + provider.getService() + ", " + provider.getRegion());
            } catch (Throwable t) {
                registerLog.log(Level.INFO, "Amazon SMS Notification Provider is off");
                registerLog.log(Level.FINE, "reason: ", t);
            }
        }

        public void contextDestroyed(ServletContextEvent servletContextEvent) {
            ServletContext servletContext = servletContextEvent.getServletContext();

            try {
                boolean deleteTopics = false;
                String paramValue = servletContext.getInitParameter(DELETE_TOPICS_KEY);
                if (paramValue != null) {
                    deleteTopics = Boolean.parseBoolean(paramValue);
                }
                provider.shutdown(deleteTopics);

//                Object[] extensions = ExtensionRegistry.getExtensions(servletContext, NotificationProvider.class.getName());
//                for (int i = 0; i < extensions.length; i++) {
//                    if (extensions[i] instanceof AmazonSNSNotificationProvider) {
//                        ((AmazonSNSNotificationProvider) extensions[i]).shutdown(deleteTopics);
//                        registerLog.log(Level.INFO, "shutting down");
//                        break;
//                    }
//                }
            } catch (Throwable t) {
                registerLog.log(Level.FINE, "problem shutting down ", t);
            }
        }


    }

}
