/*
 * The MIT License
 * Copyright © 2016-2020 Marco Collovati ([email protected])
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package com.github.mcollovati.vertx.vaadin.sockjs.communication;

import java.io.Reader;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.vaadin.flow.component.UI;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.communication.PushConnection;
import com.vaadin.flow.server.communication.UidlWriter;
import elemental.json.JsonObject;

public class SockJSPushConnection implements PushConnection {

    private static final long serialVersionUID = -1336533816978562477L;

    private final int uiId;
    private PushSocket socket;
    private State state = State.DISCONNECTED;
    private transient Future<?> outgoingMessage;

    public SockJSPushConnection(UI ui) {
        this.uiId = ui.getUIId();
    }

    @Override
    public void push() {
        push(true);
    }

    /**
     * Pushes pending state changes and client RPC calls to the client. If
     * {@code isConnected()} is false, defers the push until a connection is
     * established.
     *
     * @param async True if this push asynchronously originates from the server,
     *              false if it is a response to a client request.
     */
    void push(boolean async) {
        if (!isConnected()) {
            if (async && state != State.RESPONSE_PENDING) {
                state = State.PUSH_PENDING;
            } else {
                state = State.RESPONSE_PENDING;
            }
        } else {
            try {
                UI ui = VaadinSession.getCurrent().getUIById(this.uiId);
                JsonObject response = new UidlWriter().createUidl(ui, async);
                sendMessage("for(;;);[" + response.toJson() + "]");
            } catch (Exception e) {
                throw new PushException("Push failed", e);
            }
        }
    }

    private void sendMessage(String message) {
        this.outgoingMessage = socket.send(message).toCompletableFuture();
    }

    protected Reader receiveMessage(Reader data) {
        // SockJS will always receive the whole message
        return data;
    }


    @Override
    public void disconnect() {
        assert isConnected();
        if (socket == null) {
            // Already disconnected. Should not happen but if it does, we don't
            // want to cause NPEs
            getLogger().fine(
                "SockJSPushConnection.disconnect() called twice, this should not happen");
            return;
        }

        if (outgoingMessage != null) {
            // Wait for the last message to be sent before closing the
            // connection (assumes that futures are completed in order)
            try {
                outgoingMessage.get(1000, TimeUnit.MILLISECONDS);
            } catch (TimeoutException e) {
                getLogger().log(Level.INFO,
                    "Timeout waiting for messages to be sent to client before disconnect");
            } catch (Exception e) {
                getLogger().log(Level.INFO,
                    "Error waiting for messages to be sent to client before disconnect");
            }
            outgoingMessage = null;
        }

        // Should block until disconnection happens
        try {
            this.socket.close().thenRun(this::connectionLost)
                .toCompletableFuture().get();
        } catch (Exception e) {
            getLogger().log(Level.INFO, "Error waiting for disconnection");
            this.connectionLost();
        }
    }

    @Override
    public boolean isConnected() {
        return state == State.CONNECTED && socket != null && socket.isConnected();
    }

    void connect(PushSocket socket) {
        assert socket != null;
        assert socket != this.socket;

        if (isConnected()) {
            disconnect();
        }

        this.socket = socket;
        State oldState = state;
        state = State.CONNECTED;

        if (oldState == State.PUSH_PENDING
            || oldState == State.RESPONSE_PENDING) {
            // Sending a "response" message (async=false) also takes care of a
            // pending push, but not vice versa
            push(oldState == State.PUSH_PENDING);
        }

    }

    void connectionLost() {
        socket = null;
        if (state == State.CONNECTED) {
            // Guard against connectionLost being (incorrectly) called when
            // state is PUSH_PENDING or RESPONSE_PENDING
            // (http://dev.vaadin.com/ticket/16919)
            state = State.DISCONNECTED;
        }
    }

    PushSocket getSocket() {
        return socket;
    }

    private static Logger getLogger() {
        return Logger.getLogger(SockJSPushConnection.class.getName());
    }

    protected enum State {
        /**
         * Not connected. Trying to push will set the connection state to
         * PUSH_PENDING or RESPONSE_PENDING and defer sending the message until
         * a connection is established.
         */
        DISCONNECTED,

        /**
         * Not connected. An asynchronous push is pending the opening of the
         * connection.
         */
        PUSH_PENDING,

        /**
         * Not connected. A response to a client request is pending the opening
         * of the connection.
         */
        RESPONSE_PENDING,

        /**
         * Connected. Messages can be sent through the connection.
         */
        CONNECTED;

    }

}