package com.priusis.client.service.core;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.io.Resources;
import com.priusis.client.data.kv.*;
import com.priusis.client.service.*;
import com.priusis.client.service.conf.*;
import com.priusis.client.service.data.*;
import com.priusis.client.util.JsonTools;
import com.priusis.monitor.mqtt.*;
import io.netty.buffer.ByteBuf;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.util.concurrent.Promise;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.springframework.beans.factory.annotation.Autowired;

import javax.annotation.PostConstruct;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.TrustManagerFactory;
import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;

import static com.priusis.client.util.JsonTools.*;

/**
 * Created by priusis on 16.01.17.
 */
@Slf4j
public class MqttServiceImpl implements MqttService, MqttHandler, MqttClientCallback {

    //private static final String GATEWAY_RPC_TOPIC = "gateway/rpc";
    //private static final String GATEWAY_ATTRIBUTES_TOPIC = "gateway/attrs";
    //private static final String GATEWAY_TELEMETRY_TOPIC = "gateway/telemetry";
    //private static final String GATEWAY_REQUESTS_ATTRIBUTES_TOPIC = "gateway/attrs/req";
    //private static final String GATEWAY_RESPONSES_ATTRIBUTES_TOPIC = "gateway/attrs/res";
    //private static final String GATEWAY_CONNECT_TOPIC = "gateway/connect";
    //private static final String GATEWAY_DISCONNECT_TOPIC = "gateway/disconnect";
    //private static final String GATEWAY = "GATEWAY";

    private static final String DEVICE_TELEMETRY_TOPIC = "devices/telemetry";
    private static final String DEVICE_ATTRIBUTES_TOPIC = "devices/attrs";
    private static final String DEVICE_RPC_TOPIC = "devices/rpc";
    private static final String DEVICE_GET_ATTRIBUTES_REQUEST_TOPIC = "devices/attrs/req/1";
    private static final String DEVICE_GET_ATTRIBUTES_RESPONSE_PLUS_TOPIC = "devices/attrs/res/+";
    private static final String DEVICE_GET_ATTRIBUTES_RESPONSE_TOPIC = "devices/attrs/res/1";

    private static final String JKS = "JKS";
    private static final long DEFAULT_CONNECTION_TIMEOUT = 10000;
    private static final long DEFAULT_POLLING_INTERVAL = 1000;

    private final ConcurrentMap<String, DeviceInfo> devices = new ConcurrentHashMap<>();
    private final AtomicLong attributesCount = new AtomicLong();
    private final AtomicLong telemetryCount = new AtomicLong();
    private final AtomicInteger msgIdSeq = new AtomicInteger();
    private final Set<AttributesUpdateSubscription> attributeUpdateSubs = ConcurrentHashMap.newKeySet();
    private final Map<AttributeRequestKey, AttributeRequestListener> pendingAttrRequestsMap = new ConcurrentHashMap<>();

    private String tenantLabel;

    private PersistentFileService persistentFileService;

    private Consumer<String> extensionsConfigListener;
    private PcCoreConfiguration configuration;
    private PcConnectionConfiguration connection;
    private PcReportingConfiguration reporting;
    private PcPersistenceConfiguration persistence;


    private volatile ObjectNode error;
    private MqttClient tbClient;

    private ScheduledExecutorService scheduler;
    private ExecutorService mqttSenderExecutor;
    private ExecutorService mqttReceiverExecutor;
    private ExecutorService callbackExecutor = Executors.newCachedThreadPool();

    @Autowired
    private NioEventLoopGroup nioEventLoopGroup;


    public MqttServiceImpl(PcCoreConfiguration configuration, Consumer<String> extensionsConfigListener) {
        this.configuration = configuration;
        this.extensionsConfigListener = extensionsConfigListener;
    }

    @Override
    @PostConstruct
    public void init() {
        BlockingQueue<MessageFuturePair> incomingQueue = new LinkedBlockingQueue<>();
        this.tenantLabel = configuration.getLabel();
        this.connection = configuration.getConnection();
        this.reporting = configuration.getReporting();
        this.persistence = configuration.getPersistence();
        this.tenantLabel = configuration.getLabel();
        initTimeouts();
        initMqttClient();
        initMqttSender(incomingQueue);
        initMqttReceiver(incomingQueue);
        scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(this::reportStats, 0, reporting.getInterval(), TimeUnit.MILLISECONDS);
    }

    private void initTimeouts() {
        // Backwards compatibility with old config file
        if (connection.getConnectionTimeout() == 0) {
            connection.setConnectionTimeout(DEFAULT_CONNECTION_TIMEOUT);
        }
        if (persistence.getPollingInterval() == 0) {
            persistence.setPollingInterval(DEFAULT_POLLING_INTERVAL);
        }
    }

    @Override
    public void destroy() throws Exception {
        scheduler.shutdownNow();
        callbackExecutor.shutdownNow();
        mqttSenderExecutor.shutdownNow();
        mqttReceiverExecutor.shutdownNow();
        tbClient.disconnect();
    }

    @Override
    public String getTenantLabel() {
        return tenantLabel;
    }

    @Override
    public MqttDeliveryFuture onDeviceAttributesUpdate(List<KvEntry> attributes) {
        final int msgId = msgIdSeq.incrementAndGet();
        log.trace("[{}] Updating device attributes: {}", msgId, attributes);
        ObjectNode node = newNode();
        //ObjectNode deviceNode = node.putObject(deviceName);
        attributes.forEach(kv -> putToNode(node, kv));
        final int packSize = attributes.size();
        return persistMessage(DEVICE_ATTRIBUTES_TOPIC, msgId, toNodeBytes(node),
                message -> {
                    log.debug("[{}] Device attributes were delivered!", msgId);
                    attributesCount.addAndGet(packSize);
                },
                error -> log.warn("[{}] Failed to report device attributes!", msgId, error));
    }

    private MqttDeliveryFuture persistMessage(String topic,
                                              int msgId,
                                              byte[] payload,
                                              Consumer<Void> onSuccess,
                                              Consumer<Throwable> onFailure) {
        try {
            return persistentFileService.persistMessage(topic, msgId, payload, onSuccess, onFailure);
        } catch (Throwable e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }

    @Override
    public void onDeviceAttributeRequest(AttributeRequest request, Consumer<AttributeResponse> listener) {
        final int msgId = msgIdSeq.incrementAndGet();
        String deviceName = request.getDeviceName();
        AttributeRequestKey requestKey = new AttributeRequestKey(request.getRequestId(), request.getDeviceName());
        log.trace("[{}][{}] Requesting {} attribute: {}", deviceName, msgId, request.isClientScope() ? "client" : "shared", request.getAttributeKey());

        ObjectNode node = newNode();
        node.put("id", request.getRequestId());
        node.put("client", request.isClientScope());
        node.put("device", request.getDeviceName());
        node.put("key", request.getAttributeKey());
        MqttMessage msg = new MqttMessage(toBytes(node));

        msg.setId(msgId);
        pendingAttrRequestsMap.put(requestKey, new AttributeRequestListener(request, listener));
        persistMessage(DEVICE_GET_ATTRIBUTES_REQUEST_TOPIC, msgId, toBytes(node),
                message -> {
                    log.debug("[{}] Device attributes request was delivered!", msgId);
                },
                error -> {
                    log.warn("[{}] Failed to report device attributes!", msgId, error);
                    pendingAttrRequestsMap.remove(requestKey);
                });
    }

    @Override
    public void onDeviceRpcResponse(RpcCommandResponse response) {
        final int msgId = msgIdSeq.incrementAndGet();
        int requestId = response.getRequestId();
        String data = response.getData();

        ObjectNode node = newNode();
        node.put("id", requestId);
        node.put("data", data);
        persistMessage(DEVICE_RPC_TOPIC, msgId, toBytes(node),
                token -> {
                    log.debug("[{}] RPC response from device was delivered!", requestId);
                },
                error -> {
                    log.warn("[{}] Failed to report RPC response from device!", requestId, error);
                });
    }

    @Override
    public boolean subscribe(AttributesUpdateSubscription subscription) {
        return subscribe(attributeUpdateSubs::add, subscription);
    }

    @Override
    public boolean unsubscribe(AttributesUpdateSubscription subscription) {
        return unsubscribe(attributeUpdateSubs::remove, subscription);
    }

    @Override
    public void onError(Exception e) {
        onError(null, e);
    }

    @Override
    public void onError(String deviceName, Exception e) {
        ObjectNode node = newNode();
        node.put("ts", System.currentTimeMillis());
        if (deviceName != null) {
            node.put("device", deviceName);
        }
        node.put("error", toString(e));
        error = node;
    }

    private void reportStats() {
        try {
            ObjectNode node = newNode();
            node.put("ts", System.currentTimeMillis());
            ObjectNode valuesNode = node.putObject("values");

            valuesNode.put("devicesOnline", devices.size());
            valuesNode.put("attributesUploaded", attributesCount.getAndSet(0));
            valuesNode.put("telemetryUploaded", telemetryCount.getAndSet(0));
            if (error != null) {
                valuesNode.put("latestError", JsonTools.toString(error));
                error = null;
            }
            persistMessage(DEVICE_TELEMETRY_TOPIC, msgIdSeq.incrementAndGet(), toBytes(node),
                    token -> log.info("Gateway statistics {} reported!", node),
                    error -> log.warn("Failed to report gateway statistics!", error));
        } catch (Throwable e) {
            log.error("Failed to persist statistics message!", e);
        }
    }

    @Override
    public void onMessage(String topic, ByteBuf payload) {
        String message = payload.toString(StandardCharsets.UTF_8);

        log.trace("Message arrived [{}] {}", topic, message);
        callbackExecutor.submit(() -> {
            try {
                if (topic.equals(DEVICE_ATTRIBUTES_TOPIC)) {
                    onAttributesUpdate(message);
                } else if (topic.equals(DEVICE_GET_ATTRIBUTES_RESPONSE_PLUS_TOPIC)) {
                    onDeviceAttributesResponse(message);
                } else if (topic.equals(DEVICE_RPC_TOPIC)) {
                    onRpcCommand(message);
                } else if (topic.equals(DEVICE_ATTRIBUTES_TOPIC)) {
                    onGatewayAttributesUpdate(message);
                } else if (topic.equals(DEVICE_GET_ATTRIBUTES_RESPONSE_TOPIC)) {
                    onGatewayAttributesGet(message);
                }
            } catch (Exception e) {
                log.warn("Failed to process arrived message!", message);
            }
        });

    }

    @Override
    public void connectionLost(Throwable throwable) {
        log.warn("Lost connection to Priusisiot.");
        pendingAttrRequestsMap.clear();
        devices.clear();
    }

    @Override
    public void onSuccessfulReconnect() {

    }

    private void onAttributesUpdate(String message) {
        JsonNode payload = fromString(message);
        String deviceName = payload.get("device").asText();
        Set<AttributesUpdateListener> listeners = attributeUpdateSubs.stream()
                .filter(sub -> sub.matches(deviceName)).map(sub -> sub.getListener())
                .collect(Collectors.toSet());
        if (!listeners.isEmpty()) {
            JsonNode data = payload.get("data");
            List<KvEntry> attributes = getKvEntries(data);
            listeners.forEach(listener -> callbackExecutor.submit(() -> {
                try {
                    listener.onAttributesUpdated(deviceName, attributes);
                } catch (Exception e) {
                    log.error("[{}] Failed to process attributes update", deviceName, e);
                }
            }));
        }
    }

    private void onRpcCommand(String message) {
        JsonNode payload = fromString(message);
        MqttRpcDataMessage mqttRpcDataMessage = MqttRpcDataMessage.builder().requestId(payload.get("id").asInt())
                .method(payload.get("method").asText()).params(payload.get("params").asText()).build();

        // 存储rpc下发的数据
        try {
            persistentFileService.flushRpcDataToFile(mqttRpcDataMessage);
        } catch (IOException e) {
            log.error("Failed to process rpc command persistent : {}", message);
        }
    }

    private void onGatewayAttributesGet(String message) {
        log.info("Configuration arrived! {}", message);
        JsonNode payload = fromString(message);
        if (payload.get("shared").get("configuration") != null) {
            String configuration = payload.get("shared").get("configuration").asText();
            if (!StringUtils.isEmpty(configuration)) {
                updateConfiguration(configuration);
            }
        }
    }

    private void onGatewayAttributesUpdate(String message) {
        log.info("Configuration updates arrived! {}", message);
        JsonNode payload = fromString(message);
        if (payload.has("configuration")) {
            String configuration = payload.get("configuration").asText();
            if (!StringUtils.isEmpty(configuration)) {
                updateConfiguration(configuration);
            }
        }
    }

    private void updateConfiguration(String configuration) {
        try {
            if (extensionsConfigListener != null) {
                extensionsConfigListener.accept(configuration);
            }
            onAppliedConfiguration(configuration);
        } catch (Exception e) {
            log.warn("Failed to update extension configurations [[]]", e.getMessage(), e);
        }
    }

    @Override
    public void onAppliedConfiguration(String configuration) {
        byte[] msgData = toBytes(newNode().put("appliedConfiguration", configuration));
        persistMessage(DEVICE_ATTRIBUTES_TOPIC, msgIdSeq.incrementAndGet(), msgData, null,
                error ->
                        log.warn("Could not publish applied configuration", error));
    }

    @Override
    public void onConfigurationError(Exception e, PcExtensionConfiguration configuration) {
        String id = configuration.getId();
        byte[] msgDataError = toBytes(newNode().put(id + "ExtensionError", toString(e)));
        persistMessage(DEVICE_TELEMETRY_TOPIC, msgIdSeq.incrementAndGet(), msgDataError, null,
                error -> log.warn("Could not report extension error", error));

        byte[] msgDataStatus = toBytes(newNode().put(id + "ExtensionStatus", "Failure"));
        persistMessage(DEVICE_TELEMETRY_TOPIC, msgIdSeq.incrementAndGet(), msgDataStatus, null,
                error -> log.warn("Could not report extension error status", error));
    }

    @Override
    public void onConfigurationStatus(String id, String status) {
        byte[] extentionStatusData = toBytes(newNode().put(id + "ExtensionStatus", status));
        persistMessage(DEVICE_TELEMETRY_TOPIC, msgIdSeq.incrementAndGet(), extentionStatusData,
                message -> log.info("Reported status [{}] of extension [{}]", status, id),
                error -> log.warn("Extension status reporting failed", error));


        byte[] extentionErrorData = toBytes(newNode().put(id + "ExtensionError", ""));
        persistMessage(DEVICE_TELEMETRY_TOPIC, msgIdSeq.incrementAndGet(), extentionErrorData,
                null, error ->
                        log.warn("Extension error clearing failed", error));
    }

    @Override
    public File flushRpcDataToFile(MqttRpcDataMessage mqttRpcDataMessage) throws IOException {
        return persistentFileService.flushRpcDataToFile(mqttRpcDataMessage);
    }

    @Override
    public MqttRpcDataMessage readFromFile(String method) throws IOException {
        return persistentFileService.readFromFile(method);
    }

    private void onDeviceAttributesResponse(String message) {
        JsonNode payload = fromString(message);
        AttributeRequestKey requestKey = new AttributeRequestKey(payload.get("id").asInt(), payload.get("device").asText());

        AttributeRequestListener listener = pendingAttrRequestsMap.get(requestKey);
        if (listener == null) {
            log.warn("[{}][{}] Can't find listener for request", requestKey.getDeviceName(), requestKey.getRequestId());
            return;
        }

        AttributeRequest request = listener.getRequest();
        AttributeResponse.AttributeResponseBuilder response = AttributeResponse.builder();

        response.requestId(request.getRequestId());
        response.deviceName(request.getDeviceName());
        response.key(request.getAttributeKey());
        response.clientScope(request.isClientScope());
        response.topicExpression(request.getTopicExpression());
        response.valueExpression(request.getValueExpression());

        String key = listener.getRequest().getAttributeKey();
        JsonNode value = payload.get("value");
        if (value == null) {
            response.data(Optional.empty());
        } else if (value.isBoolean()) {
            response.data(Optional.of(new BooleanDataEntry(key, value.asBoolean())));
        } else if (value.canConvertToLong()) {
            response.data(Optional.of(new LongDataEntry(key, value.asLong())));
        } else if (value.isDouble()) {
            response.data(Optional.of(new DoubleDataEntry(key, value.asDouble())));
        } else {
            response.data(Optional.of(new StringDataEntry(key, value.asText())));
        }

        callbackExecutor.submit(() -> {
            try {
                listener.getListener().accept(response.build());
            } catch (Exception e) {
                log.error("[{}][{}] Failed to process attributes response", requestKey.getDeviceName(), requestKey.getRequestId(), e);
            }
        });
    }

    private void initMqttSender(BlockingQueue<MessageFuturePair> incomingQueue) {
        mqttSenderExecutor = Executors.newSingleThreadExecutor();
        mqttSenderExecutor.submit(new MqttMessageSender(persistence, connection, tbClient, persistentFileService, incomingQueue));
    }

    private void initMqttReceiver(BlockingQueue<MessageFuturePair> incomingQueue) {
        mqttReceiverExecutor = Executors.newSingleThreadExecutor();
        mqttReceiverExecutor.submit(new MqttMessageReceiver(persistentFileService, incomingQueue, connection.getIncomingQueueWarningThreshold()));
    }

    private static String toString(Exception e) {
        StringWriter sw = new StringWriter();
        e.printStackTrace(new PrintWriter(sw));
        return sw.toString();
    }

    private <T> boolean subscribe(Function<T, Boolean> f, T sub) {
        if (f.apply(sub)) {
            log.info("Subscription added: {}", sub);
            return true;
        } else {
            log.warn("Subscription was already added: {}", sub);
            return false;
        }
    }

    private <T> boolean unsubscribe(Function<T, Boolean> f, T sub) {
        if (f.apply(sub)) {
            log.info("Subscription removed: {}", sub);
            return true;
        } else {
            log.warn("Subscription was already removed: {}", sub);
            return false;
        }
    }

    private MqttClient initMqttClient() {
        try {
            MqttClientConfig mqttClientConfig = getMqttClientConfig();
            mqttClientConfig.setUsername(connection.getSecurity().getAccessToken());
            tbClient = MqttClient.create(mqttClientConfig, this);
            tbClient.setCallback(this);
            tbClient.setEventLoop(nioEventLoopGroup);
            connectTbClient();


            tbClient.on(DEVICE_ATTRIBUTES_TOPIC, this).await(connection.getConnectionTimeout(), TimeUnit.MILLISECONDS);
            tbClient.on(DEVICE_GET_ATTRIBUTES_RESPONSE_PLUS_TOPIC, this).await(connection.getConnectionTimeout(), TimeUnit.MILLISECONDS);
            tbClient.on(DEVICE_GET_ATTRIBUTES_RESPONSE_TOPIC, this).await(connection.getConnectionTimeout(), TimeUnit.MILLISECONDS);
            tbClient.on(DEVICE_RPC_TOPIC, this).await(connection.getConnectionTimeout(), TimeUnit.MILLISECONDS);

            byte[] msgData = toBytes(newNode().put("sharedKeys", "configuration"));
            persistMessage(DEVICE_GET_ATTRIBUTES_REQUEST_TOPIC, msgIdSeq.incrementAndGet(), msgData,
                    null,
                    error -> log.warn("Error getiing attributes", error));
            return tbClient;
        } catch (InterruptedException e) {
            log.error(e.getMessage(), e);
            Thread.currentThread().interrupt();
            return null;
        } catch (ExecutionException e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }

    private void connectTbClient() throws InterruptedException, ExecutionException {
        Promise<MqttConnectResult> connectResult = (Promise<MqttConnectResult>) tbClient.connect(connection.getHost(), connection.getPort());
        connectResult.addListener(future -> {
            if (future.isSuccess()) {
                MqttConnectResult result = (MqttConnectResult) future.getNow();
                log.debug("Gateway connect result code: [{}]", result.getReturnCode());
            } else {
                log.error("Unable to connect to mqtt server!");
                if (future.cause() != null) {
                    log.error(future.cause().getMessage(), future.cause());
                }
            }
        });
        try {
            connectResult.get(connection.getConnectionTimeout(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            log.error("Unable to connect to mqtt server try again!");
            String message = "Unable to connect to Priusisiot. Connection timed out after [" + connection.getConnectionTimeout() + "] milliseconds";
            log.error(message, e);
            Thread.sleep(connection.getRetryInterval());
            connectTbClient();
        }
    }

    private MqttClientConfig getMqttClientConfig() {
        MqttClientConfig mqttClientConfig;
        if (!StringUtils.isEmpty(connection.getSecurity().getAccessToken())) {
            if (StringUtils.isEmpty(connection.getSecurity().getTruststore())) {
                mqttClientConfig = new MqttClientConfig();
                mqttClientConfig.setUsername(connection.getSecurity().getAccessToken());
            } else {
                try {
                    SslContext sslCtx = initOneWaySslContext(connection.getSecurity());
                    mqttClientConfig = new MqttClientConfig(sslCtx);
                } catch (Exception e) {
                    log.error(e.getMessage(), e);
                    throw new RuntimeException(e);
                }
            }
        } else {
            try {
                SslContext sslCtx = initTwoWaySslContext(connection.getSecurity());
                mqttClientConfig = new MqttClientConfig(sslCtx);
            } catch (Exception e) {
                log.error(e.getMessage(), e);
                throw new RuntimeException(e);
            }
        }
        return mqttClientConfig;
    }

    private SslContext initOneWaySslContext(MqttClientSecurityConfiguration configuration) throws Exception {
        URL tsUrl = Resources.getResource(configuration.getTruststore());
        File tsFile = new File(tsUrl.toURI());

        TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        KeyStore trustStore = KeyStore.getInstance(JKS);
        try (InputStream tsFileInputStream = new FileInputStream(tsFile)) {
            trustStore.load(tsFileInputStream, configuration.getTruststorePassword().toCharArray());
        }
        tmf.init(trustStore);

        return SslContextBuilder.forClient().trustManager(tmf).build();
    }

    private SslContext initTwoWaySslContext(MqttClientSecurityConfiguration configuration) throws Exception {
        URL ksUrl = Resources.getResource(configuration.getKeystore());
        File ksFile = new File(ksUrl.toURI());
        URL tsUrl = Resources.getResource(configuration.getTruststore());
        File tsFile = new File(tsUrl.toURI());

        TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        KeyStore trustStore = KeyStore.getInstance(JKS);
        try (InputStream tsFileInputStream = new FileInputStream(tsFile)) {
            trustStore.load(tsFileInputStream, configuration.getTruststorePassword().toCharArray());
        }
        tmf.init(trustStore);

        KeyStore keyStore = KeyStore.getInstance(JKS);
        try (InputStream ksFileInputStream = new FileInputStream(ksFile)) {
            keyStore.load(ksFileInputStream, configuration.getKeystorePassword().toCharArray());
        }
        KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        kmf.init(keyStore, configuration.getKeystorePassword().toCharArray());

        return SslContextBuilder.forClient().keyManager(kmf).trustManager(tmf).build();
    }

    public void setPersistentFileService(PersistentFileService persistentFileService) {
        this.persistentFileService = persistentFileService;
    }
}
