/*
 * Copyright 2014 Sebastien Dionne
 *
 * Licensed 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 org.atmosphere.socketio.transport;

import org.atmosphere.cache.BroadcastMessage;
import org.atmosphere.cpr.Action;
import org.atmosphere.cpr.ApplicationConfig;
import org.atmosphere.cpr.AtmosphereHandler;
import org.atmosphere.cpr.AtmosphereRequest;
import org.atmosphere.cpr.AtmosphereResourceEvent;
import org.atmosphere.cpr.AtmosphereResourceEventListenerAdapter;
import org.atmosphere.cpr.AtmosphereResourceImpl;
import org.atmosphere.cpr.AtmosphereResponse;
import org.atmosphere.cpr.DefaultBroadcaster;
import org.atmosphere.socketio.SocketIOClosedException;
import org.atmosphere.socketio.SocketIOException;
import org.atmosphere.socketio.SocketIOPacket;
import org.atmosphere.socketio.SocketIOSession;
import org.atmosphere.socketio.SocketIOSessionFactory;
import org.atmosphere.socketio.SocketIOSessionOutbound;
import org.atmosphere.socketio.cpr.SocketIOAtmosphereHandler;
import org.atmosphere.socketio.transport.SocketIOPacketImpl.PacketType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author Sebastien Dionne  : sebastien.dionne@gmail.com
 * @author Jeanfrancois Arcand
 */
public abstract class XHRTransport extends AbstractTransport {

    private static final Logger logger = LoggerFactory.getLogger(XHRTransport.class);
    private final int bufferSize;

    protected abstract class XHRSessionHelper implements SocketIOSessionOutbound {
        private final ReentrantLock lock = new ReentrantLock();

        protected final SocketIOSession session;
        private volatile boolean is_open = false;
        private final boolean isStreamingConnection;

        XHRSessionHelper(SocketIOSession session, boolean isConnectionPersistant) {
            this.session = session;
            this.isStreamingConnection = isConnectionPersistant;
        }

        protected abstract void startSend(AtmosphereResponse response) throws IOException;

        protected abstract void writeData(AtmosphereResponse response, String data) throws IOException;

        protected abstract void finishSend(AtmosphereResponse response) throws IOException;

        @Override
        public void disconnect() {
            synchronized (this) {
                session.onDisconnect(DisconnectReason.DISCONNECT);
                abort();
            }
        }

        @Override
        public void close() {
            synchronized (this) {
                session.startClose();
            }
        }


        @Override
        public void sendMessage(SocketIOPacket packet) throws SocketIOException {
            if (packet != null) {
                sendMessage(packet.toString());
            }
        }

        @Override
        public void sendMessage(List<SocketIOPacketImpl> messages) throws SocketIOException {
            if (messages != null) {
                for (SocketIOPacketImpl msg : messages) {
                    switch (msg.getFrameType()) {
                        case MESSAGE:
                        case JSON:
                        case EVENT:
                        case ACK:
                        case ERROR:
                            msg.setPadding(messages.size() > 1);
                            try {
                                sendMessage(msg.toString());
                            } catch (Exception e) {

                                AtmosphereResourceImpl resource = session.getAtmosphereResourceImpl();
                                // if BroadcastCache is available, add the message to the cache
                                if (resource != null && DefaultBroadcaster.class.isAssignableFrom(resource.getBroadcaster().getClass())) {
                                    resource.getBroadcaster().getBroadcasterConfig().getBroadcasterCache().
                                            addToCache(resource.getBroadcaster().getID(), resource.uuid(),
                                                    new BroadcastMessage(msg));
                                }
                            }
                            break;
                        default:
                            logger.error("Unknown SocketIOEvent msg = " + msg);
                    }
                }
            }
        }

        @Override
        public void sendMessage(String message) throws SocketIOException {
            logger.trace("Session[" + session.getSessionId() + "]: " + "sendMessage(String): " + message);

            synchronized (this) {
                if (is_open) {

                    AtmosphereResourceImpl resource = session.getAtmosphereResourceImpl();

                    logger.trace("Session[" + session.getSessionId() + "]: " + resource.getRequest().getMethod() + "sendMessage");

                    try {
                        writeData(resource.getResponse(), message);
                    } catch (Exception e) {
                        if (!resource.isCancelled()) {
                            logger.trace("calling from " + this.getClass().getName() + " : " + "sendMessage ON FORCE UN RESUME");
                            try {
                                finishSend(resource.getResponse());
                            } catch (IOException ex) {
                                logger.trace("", ex);
                            }

                            resource.resume();
                        }
                        throw new SocketIOException(e);
                    }
                    if (!isStreamingConnection) {
                        try {
                            finishSend(resource.getResponse());
                        } catch (IOException e) {
                            logger.trace("", e);
                        }
                        resource.resume();
                    } else {
                        logger.trace("calling from " + this.getClass().getName() + " : " + "sendMessage");
                        session.startHeartbeatTimer();
                    }
                } else {
                    logger.trace("calling from " + this.getClass().getName() + " : " + "SocketIOClosedException sendMessage");
                    throw new SocketIOClosedException();
                }
            }

        }

        @Override
        public Action handle(AtmosphereRequest request, final AtmosphereResponse response, SocketIOSession session) throws IOException {
            logger.trace("Session id[" + session.getSessionId() + "] method=" + request.getMethod() + "  response HashCode=" + response.hashCode());

            AtmosphereResourceImpl resource = (AtmosphereResourceImpl) request.getAttribute(ApplicationConfig.ATMOSPHERE_RESOURCE);

            if ("GET".equals(request.getMethod())) {
                synchronized (this) {
                    if (!is_open) {
                        response.sendError(AtmosphereResponse.SC_NOT_FOUND);
                    } else {
                        if (!isStreamingConnection) {
                            if (resource != null) {
                                resource.getRequest().setAttribute(SocketIOAtmosphereHandler.SOCKETIO_SESSION_ID, session.getSessionId());
                                resource.getRequest().setAttribute(SocketIOAtmosphereHandler.SOCKETIO_SESSION_OUTBOUND, session.getTransportHandler());
                                session.setAtmosphereResourceImpl(resource);

                                resource.addEventListener(new AtmosphereResourceEventListenerAdapter() {
                                    @Override
                                    public void onResume(AtmosphereResourceEvent event) {
                                        if (event.isResumedOnTimeout()) {
                                            event.getResource().write(new SocketIOPacketImpl(PacketType.NOOP).toString());
                                        }
                                    }
                                });

                                session.clearTimeoutTimer();
                                request.setAttribute(SESSION_KEY, session);
                                boolean resume = false;

                                StringBuilder data = new StringBuilder();
                                // if there is a Broadcaster cache, retrieve the messages from the cache, and send them
                                if (DefaultBroadcaster.class.isAssignableFrom(resource.getBroadcaster().getClass())) {

                                    List<Object> cachedMessages = resource.getBroadcaster().getBroadcasterConfig().getBroadcasterCache()
                                            .retrieveFromCache(resource.getBroadcaster().getID(), resource.uuid());

                                    if (cachedMessages != null && !cachedMessages.isEmpty()) {
                                        if (cachedMessages.size() > 1) {
                                            for (Object object : cachedMessages) {
                                                String msg = object.toString();
                                                data.append(SocketIOPacketImpl.SOCKETIO_MSG_DELIMITER)
                                                        .append(msg.length())
                                                        .append(SocketIOPacketImpl.SOCKETIO_MSG_DELIMITER).append(msg);
                                            }
                                        } else if (cachedMessages.size() == 1) {
                                            data.append(cachedMessages.get(0));
                                        }

                                        // something to send ?
                                        if (!data.toString().isEmpty()) {
                                            startSend(response);
                                            writeData(response, data.toString());
                                            finishSend(response);

                                            // force a resume, because we sent data
                                            resource.resume();

                                            resume = true;
                                        }
                                    }

                                }

                                if (!resume) {
                                    resource.disableSuspend(false);
                                    resource.suspend(session.getRequestSuspendTime());
                                    resource.disableSuspend(true);
                                }
                            }
                        } else {
                            // won't happend, by should be for xhr-streaming transport
                            response.sendError(AtmosphereResponse.SC_NOT_FOUND);
                        }
                    }
                }
            } else if ("POST".equals(request.getMethod())) {
                if (is_open) {
                    int size = request.getContentLength();
                    if (size == 0) {
                        response.sendError(AtmosphereResponse.SC_BAD_REQUEST);
                    } else {
                        String data = (String) request.getAttribute(POST_MESSAGE_RECEIVED);
                        if (data == null) {
                            data = decodePostData(request.getContentType(), extractString(request.getReader()));
                        }
                        if (data == null || data.length() == 0) {
                            data = SocketIOSessionManagerImpl.mapper.readValue(request.getParameter("d"), String.class);
                        }
                        if (data != null && data.length() > 0) {

                            List<SocketIOPacketImpl> list = SocketIOPacketImpl.parse(data);

                            synchronized (session) {
                                for (SocketIOPacketImpl msg : list) {

                                    if (msg.getFrameType().equals(SocketIOPacketImpl.PacketType.EVENT)) {

                                        // send message on the suspended request
                                        session.onMessage(session.getAtmosphereResourceImpl(), session.getTransportHandler(), msg.getData());

                                        // send completion flag on the post request
                                        writeData(response, SocketIOPacketImpl.POST_RESPONSE);

                                    } else {
                                        // send completion flag on the post request
                                        writeData(response, SocketIOPacketImpl.POST_RESPONSE);
                                    }
                                }
                            }
                        }
                    }
                    // force a resume on a POST request
                    resource.resume();
                }
            } else {
                response.sendError(AtmosphereResponse.SC_BAD_REQUEST);
            }
            return Action.CANCELLED;
        }

        protected abstract void customConnect(AtmosphereRequest request, AtmosphereResponse response) throws IOException;

        public void connect(AtmosphereResourceImpl resource) throws IOException {

            AtmosphereRequest request = resource.getRequest();
            AtmosphereResponse response = resource.getResponse();

            request.setAttribute(SESSION_KEY, session);
            response.setBufferSize(bufferSize);

            customConnect(request, response);
            is_open = true;
            session.onConnect(resource, this);
            finishSend(response);

            if (isStreamingConnection) {
                resource.suspend();
            }
        }

        @Override
        public void abort() {
            logger.error("calling from " + this.getClass().getName() + " : " + "abort");
            session.clearHeartbeatTimer();
            session.clearTimeoutTimer();
            is_open = false;
            session.onShutdown();

            // force a resume
            session.getAtmosphereResourceImpl().resume();
        }

        @Override
        public String getSessionId() {
            return session.getSessionId();
        }
    }

    public XHRTransport(int bufferSize) {
        this.bufferSize = bufferSize;
    }

    /**
     * This method should only be called within the context of an active HTTP request.
     */
    protected abstract XHRSessionHelper createHelper(SocketIOSession session);


    protected SocketIOSession connect(SocketIOSession session, AtmosphereResourceImpl resource,
                                      AtmosphereHandler atmosphereHandler, SocketIOSessionFactory sessionFactory) throws IOException {

        if (session == null) {
            session = sessionFactory.createSession(resource, atmosphereHandler);
            resource.getRequest().setAttribute(SocketIOAtmosphereHandler.SOCKETIO_SESSION_ID, session.getSessionId());
        }

        XHRSessionHelper handler = createHelper(session);
        handler.connect(resource);
        return session;
    }

    protected SocketIOSession connect(AtmosphereResourceImpl resource, AtmosphereHandler atmosphereHandler, SocketIOSessionFactory sessionFactory) throws IOException {
        return connect(null, resource, atmosphereHandler, sessionFactory);
    }

    @Override
    public Action handle(AtmosphereResourceImpl resource, AtmosphereHandler atmosphereHandler, SocketIOSessionFactory sessionFactory) throws IOException {

        AtmosphereRequest request = resource.getRequest();
        AtmosphereResponse response = resource.getResponse();

        Object obj = request.getAttribute(SESSION_KEY);
        SocketIOSession session = null;
        String sessionId = null;
        if (obj != null) {
            session = (SocketIOSession) obj;
        } else {
            sessionId = extractSessionId(request);
            if (sessionId != null && sessionId.length() > 0) {
                session = sessionFactory.getSession(sessionId);
            }
        }

        boolean isDisconnectRequest = isDisconnectRequest(request);
        Action action = Action.CONTINUE;
        if (session != null) {
            SocketIOSessionOutbound handler = session.getTransportHandler();
            if (handler != null) {
                if (!isDisconnectRequest) {
                    action = handler.handle(request, response, session);
                } else {
                    handler.disconnect();
                    response.setStatus(200);
                }
            } else {
                if (!isDisconnectRequest) {
                    // handle the Connect
                    session = connect(session, resource, atmosphereHandler, sessionFactory);
                    if (session == null) {
                        response.sendError(AtmosphereResponse.SC_SERVICE_UNAVAILABLE);
                    }
                } else {
                    response.setStatus(200);
                }
            }
        } else if (sessionId != null && sessionId.length() > 0) {
            logger.trace("Session NULL but not sessionId : wrong session id or the connection was DISCONNECTED");
            response.sendError(AtmosphereResponse.SC_BAD_REQUEST);
        } else {
            if ("GET".equals(request.getMethod())) {
                session = connect(resource, atmosphereHandler, sessionFactory);
                if (session == null) {
                    response.sendError(AtmosphereResponse.SC_SERVICE_UNAVAILABLE);
                }
            } else {
                response.sendError(AtmosphereResponse.SC_BAD_REQUEST);
            }
        }

        return action;
    }

}
