/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.netbeans.modules.jshell.launch;

import com.sun.jdi.ObjectReference;
import com.sun.jdi.VirtualMachine;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.channels.Channels;
import java.nio.channels.SocketChannel;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.netbeans.api.debugger.Session;
import org.netbeans.api.project.Project;
import org.openide.util.Exceptions;

/**
 *
 * Joins the DebuggerEngine being launched and the JShell agent socket from 
 * the debugged VM.
 * The connection (listening socket) is created from StartupExtender when the application
 * is being launched. The connection is activated when the project JPDA Debugger 
 * <p/>
 * The JShellConnection can be used even before the handshake completes. When the handshake
 * completes (success or failure)
 * <p/>
 * The Connection transitions between states: 
 * <ul>
 * <li>invalid, not connected, not closed, debuggerSession = null
 * <li>[opt] invalid, not connected, not closed, debuggerSession = instance
 * <li>valid, was connected, not closed, debuggerSession = instance; or
 * <li>valid, was connected, not closed, debuggerSession = null - if the debugger is not available.
 * <li>invalid, was connected, not closed; or
 * <li>invalid, was connected, closed
 * </ul>
 * The connection is only valid during its operational time.
 * @author sdedic
 */
public final class JShellConnection implements AutoCloseable {
    private static final Logger LOG = Logger.getLogger(JShellConnection.class.getName());
    
    private static final int WRITE_TIMEOUT = 10000;
    
    private static final AtomicInteger connId = new AtomicInteger(0);
    
    /**
     * The project being run
     */
    private final Project   project;

    /**
     * The adress where this Connection expects connection from the agent
     */
    private final SocketAddress   listenAddress;

    /**
     * The connection ID, serving also as association key in the debugger and agent.
     */
    private final int id;
    
    /**
     * The associated debugger session, is launched in debugger.
     */
    private final Session debuggerSession;

    private final ShellAgent      theAgent;
    
    /**
     * Control socket for the JShell.
     */
    private volatile SocketChannel   controlSocket;
    
    private volatile boolean closed;
    
    /**
     * Identification of the remote agent.
     */
    private final ObjectReference  agentHandle;
    
    public Project          getProject() {
        return project;
    }
    
    JShellConnection(ShellAgent agent, SocketChannel controlSocket) throws IOException {
        this.id = connId.incrementAndGet();
        this.listenAddress = agent.getHandshakeAddress();
        this.project = agent.getProject();
        this.theAgent = agent;
        
        this.controlSocket = controlSocket;
        this.ostm = NIOStreams.createOutputStream(controlSocket, WRITE_TIMEOUT);
        this.istm = new CloseInputStream(NIOStreams.createInputStream(controlSocket, this::disconnected));
        this.debuggerSession = agent.getDebuggerSession();
        
        LOG.log(Level.FINE, "Allocated connection: {0}", this);
        agentHandle = ShellDebuggerUtils.getWorkerHandle(debuggerSession, ((InetSocketAddress)controlSocket.getRemoteAddress()).getPort());
    }
    
    private void disconnected(SocketChannel dummy) {
        LOG.log(Level.FINE, "Detected disconnect: {0}", this);
        theAgent.disconnect(this, true);
    }
    
    public SocketAddress getLocalAddress() {
        return listenAddress;
    }
    
    public int getId() {
        return id;
    }
    
    /**
     * Returns agent objectref, if operating through debugger. {@code null} if 
     * debugger is no available
     * @return agent reference or null.
     */
    public ObjectReference getAgentHandle() {
        return agentHandle;
    }
    
    public int getRemoteAgentId() {
        try {
            return ((InetSocketAddress)controlSocket.getRemoteAddress()).getPort();
        } catch (IOException ex) {
            return -1;
        }
    }
    
    /**
     * Simple wrapper, which interprets close() call as local close and will
     * fire appropriate disconnect events
     */
    private class CloseInputStream extends FilterInputStream {
        private boolean closed;
        
        public CloseInputStream(InputStream in) {
            super(in);
        }

        @Override
        public  void close() throws IOException {
            synchronized (this) {
                if (closed) {
                    return;
                }
                closed = true;
            }
            try {
                super.close();
            } finally {
                LOG.log(Level.FINE, "Requested to close connection: {0}", this);
                theAgent.disconnect(JShellConnection.this, false);
            }
        }
    }
    
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("JShellConnection[").append("id = ").append(id).
                append(", session = ").append(debuggerSession).
                append(", handshake = ").append(listenAddress).
                append(", control = ").append(controlSocket).
                append("]");
        return sb.toString();
   }
    
    public ShellAgent   getMachineAgent() {
        return theAgent;
    }
    
    public void close() {
        shutDown();
    }
    
    /**
     * Shuts down the connection, may be called for local or remote close.
     */
    void shutDown() {
        try {
            LOG.log(Level.FINE, "notifyShutdown: closing control socket: {0}", controlSocket);
            if (controlSocket != null) {
                // assumes both input and output terminate
                controlSocket.close();
            }
            ostm.close();
            istm.close();
        } catch (IOException ex) {
            Exceptions.printStackTrace(ex);
        } finally {
            synchronized (this) {
                closed = true;
                notify();
            }
        }
    }
    
    synchronized boolean acceptSocketAndKey(int key, SocketChannel socket, ObjectInputStream istm) throws IOException {
        if (closed) {
            return false;
        }
        if (id != key) {
            return false;
        }
        this.ostm = Channels.newOutputStream(socket);
        this.istm = istm;
        this.controlSocket = socket;
        notify();
        return true;
    }
    
    /**
     * True, if the connection is in the process of initialization. Usually between the time the
     * debugger connects to the target VM and the remote agent connects back.
     * 
     * @return true, if the connection is initializing
     */
    public boolean isInitialized() {
        return debuggerSession != null || controlSocket != null;
    }
    
    /**
     * True, if the connection is still valid. The connection is valid until it is closed
     * or its control socket is missing or disconnected
     * @return 
     */
    public synchronized boolean isValid() {
        if (closed) {
            return false;
        }
        if (controlSocket == null || !controlSocket.isConnected() || !controlSocket.isOpen()) {
            return false;
        }
        return true;
    }
    
    public boolean isClosed() {
        return closed;
    }
    
    public Session getDebuggerSession() {
        return getMachineAgent().getDebuggerSession();
    }
    
    public VirtualMachine getVirtualMachine() {
        return getMachineAgent().getDebuggerMachine();
    }
    
    /**
     * Returns true, if the control connection was once established.
     * @return 
     */
    public boolean wasConnected() {
        return controlSocket != null;
    }
    
    public OutputStream getAgentInput() {
        return controlSocket != null ? ostm : null;
    }
    
    public InputStream getAgentOutput() {
        return controlSocket != null ? istm : null;
    }
    
    /**
     * Sends instructions to interrupt the running user code
     */
    public void interrupt() {
    }
    
    private OutputStream ostm;
    private InputStream istm;
}
