/*******************************************************************************
 * Copyright (c) 2006, 2009 Wind River Systems, Inc. and others.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 * Martin Oberhuber (Wind River) - initial API and implementation
 * David Dykstal (IBM) - 168977: refactoring IConnectorService and ServerLauncher hierarchies
 * Martin Oberhuber (Wind River) - [175686] Adapted to new IJSchService API
 *    - copied code from org.eclipse.team.cvs.ssh2/JSchSession (Copyright IBM)
 * Martin Oberhuber (Wind River) - [186773] split ISystemRegistryUI from ISystemRegistry
 * Martin Oberhuber (Wind River) - [186761] make the port setting configurable
 * Martin Oberhuber (Wind River) - [198790] make SSH createSession() protected
 * Martin Oberhuber (Wind River) - [203500] Support encodings for SSH Sftp paths
 * Martin Oberhuber (Wind River) - [155026] Add keepalives for SSH connection
 * David McKnight (IBM) - [216252] [api][nls] Resource Strings specific to subsystems should be moved from rse.ui into files.ui / shells.ui / processes.ui where possible
 * David McKnight (IBM) - [220547] [api][breaking] SimpleSystemMessage needs to specify a message id and some messages should be shared
 * Johnson Ma (Wind River) - [218880] Add UI setting for ssh keepalives
 * Martin Oberhuber (Wind River) - [227135] Cryptic exception when sftp-server is missing
 *******************************************************************************/

package org.eclipse.rse.internal.connectorservice.ssh;

import java.lang.reflect.InvocationTargetException;

import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jface.dialogs.MessageDialog;
import org.eclipse.jface.dialogs.ProgressMonitorDialog;
import org.eclipse.jface.operation.IRunnableContext;
import org.eclipse.jface.operation.IRunnableWithProgress;
import org.eclipse.jface.window.Window;
import org.eclipse.jsch.core.IJSchService;
import org.eclipse.osgi.util.NLS;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.ui.IWorkbenchWindow;
import org.eclipse.ui.PlatformUI;

import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.UIKeyboardInteractive;
import com.jcraft.jsch.UserInfo;

import org.eclipse.rse.core.RSECorePlugin;
import org.eclipse.rse.core.model.IHost;
import org.eclipse.rse.core.model.ILabeledObject;
import org.eclipse.rse.core.model.IProperty;
import org.eclipse.rse.core.model.IPropertySet;
import org.eclipse.rse.core.model.ISystemRegistry;
import org.eclipse.rse.core.model.PropertyType;
import org.eclipse.rse.core.model.SystemSignonInformation;
import org.eclipse.rse.core.subsystems.CommunicationsEvent;
import org.eclipse.rse.core.subsystems.IConnectorService;
import org.eclipse.rse.core.subsystems.SubSystemConfiguration;
import org.eclipse.rse.internal.services.ssh.ISshSessionProvider;
import org.eclipse.rse.services.clientserver.messages.CommonMessages;
import org.eclipse.rse.services.clientserver.messages.ICommonMessageIds;
import org.eclipse.rse.services.clientserver.messages.SimpleSystemMessage;
import org.eclipse.rse.services.clientserver.messages.SystemMessage;
import org.eclipse.rse.services.clientserver.messages.SystemOperationFailedException;
import org.eclipse.rse.ui.RSEUIPlugin;
import org.eclipse.rse.ui.SystemBasePlugin;
import org.eclipse.rse.ui.messages.SystemMessageDialog;
import org.eclipse.rse.ui.subsystems.StandardConnectorService;

/**
 * Create SSH connections.
 */
public class SshConnectorService extends StandardConnectorService implements ISshSessionProvider
{
	private static final int SSH_DEFAULT_PORT = 22;
	private static final int CONNECT_DEFAULT_TIMEOUT = 60; //seconds

	/** Property Keys. These are API because they are stored persistently. */
	private static final String PROPERTY_SET_SSH_SETTINGS = "SSH Settings"; //$NON-NLS-1$
	private static final String PROPERTY_KEY_TIMEOUT = "timeout(sec)"; //$NON-NLS-1$
	private static final String PROPERTY_KEY_KEEPALIVE = "keepalive(sec)"; //$NON-NLS-1$

	private Session session;
	private SessionLostHandler fSessionLostHandler;
	/** Indicates the default string encoding on this platform */
	private static String _defaultEncoding = new java.io.InputStreamReader(new java.io.ByteArrayInputStream(new byte[0])).getEncoding();

	public SshConnectorService(IHost host) {
		super(SshConnectorResources.SshConnectorService_Name, SshConnectorResources.SshConnectorService_Description, host, SSH_DEFAULT_PORT);
		fSessionLostHandler = null;
		getPropertySet();
	}

	//----------------------------------------------------------------------
	// <copied code from org.eclipse.team.cvs.ssh2/JSchSession (Copyright IBM)>
	//----------------------------------------------------------------------

	/**
	 * Create a Jsch session.
	 * Subclasses can override in order to replace the UserInfo wrapper
	 * (for non-interactive usage, for instance), or in order to change
	 * the Jsch config (for instance, in order to switch off strict
	 * host key checking or in order to add specific ciphers).
	 */
	protected Session createSession(String username, String password, String hostname, int port, UserInfo wrapperUI, IProgressMonitor monitor) throws JSchException {
		IJSchService service = Activator.getDefault().getJSchService();
		if (service == null)
			return null;
		Session session = service.createSession(hostname, port, username);

		IPropertySet propertySet = getPropertySet();
		String timeoutStr = propertySet.getPropertyValue(PROPERTY_KEY_TIMEOUT);
		int timeout = 0; //default is never timeout
		try {
			int value = Integer.parseInt(timeoutStr);
			if (value > 0) {
				timeout = value * 1000;
			}
		} catch (NumberFormatException e) {
			//wrong input - should never happen because property type is Integer
		}
		session.setTimeout(timeout);

		int keepalive = 300000; //default is 5 minutes
		String keepaliveStr = propertySet.getPropertyValue(PROPERTY_KEY_KEEPALIVE);
		try {
			int value = Integer.parseInt(keepaliveStr);
			if (value >= 0) {
				keepalive = value * 1000;
			}
		} catch (NumberFormatException e) {
			//wrong input - should never happen because property type is Integer
		}
		if (keepalive > 0) {
			session.setServerAliveInterval(keepalive);
		}


		session.setServerAliveCountMax(6); //give up after 6 tries (remote will be dead after 30 min)
		if (password != null)
			session.setPassword(password);
		session.setUserInfo(wrapperUI);
		return session;
	}

	static void shutdown() {
		//TODO: Store all Jsch sessions in a pool and disconnect them on shutdown
	}

	//----------------------------------------------------------------------
	// </copied code from org.eclipse.team.cvs.ssh2/JSchSession (Copyright IBM)>
	//----------------------------------------------------------------------

	protected int getSshPort() {
		int port = getPort();
		if (port<=0) {
			//Legacy "default port" setting
			port = SSH_DEFAULT_PORT;
		}
		return port;
	}

	protected void internalConnect(IProgressMonitor monitor) throws Exception
	{
		// Fire comm event to signal state about to change
		fireCommunicationsEvent(CommunicationsEvent.BEFORE_CONNECT);

		String host = getHostName();
		String user = getUserId();
		String password=""; //$NON-NLS-1$
		SystemSignonInformation ssi = getSignonInformation();
		if (ssi!=null) {
			password = getSignonInformation().getPassword();
		}
		MyUserInfo userInfo = new MyUserInfo(user, password);
		userInfo.aboutToConnect();

		try {
			session = createSession(user, password, host, getSshPort(),
					userInfo, monitor);

			//java.util.Hashtable config=new java.util.Hashtable();
			//config.put("StrictHostKeyChecking", "no");
			//session.setConfig(config);
			userInfo.aboutToConnect();

			Activator.trace("SshConnectorService.connecting..."); //$NON-NLS-1$
			//wait for 60 sec maximum during connect
			session.connect(CONNECT_DEFAULT_TIMEOUT * 1000);
			Activator.trace("SshConnectorService.connected"); //$NON-NLS-1$
		} catch (JSchException e) {
			Activator.trace("SshConnectorService.connect failed: "+e.toString()); //$NON-NLS-1$
			sessionDisconnect();
			if(e.toString().indexOf("Auth cancel")>=0) {  //$NON-NLS-1$
				throw new OperationCanceledException();
			}
			throw new SystemOperationFailedException(Activator.PLUGIN_ID, NLS.bind(SshConnectorResources.SshConnectorService_Missing_sshd, 
					host, Integer.toString(getSshPort())), e);
		}
		userInfo.connectionMade();
		fSessionLostHandler = new SessionLostHandler(this);
		notifyConnection();
	}

	/**
	 * Disconnect the ssh session.
	 * Synchronized in order to avoid NPE's from Jsch when called
	 * quickly in succession.
	 */
	private synchronized void sessionDisconnect() {
		Activator.trace("SshConnectorService.sessionDisconnect"); //$NON-NLS-1$
		try {
			if (session.isConnected())
				session.disconnect();
		} catch(Exception e) {
			//Bug 175328: NPE on disconnect shown in UI
			//This is a non-critical exception so print only in debug mode
			if (Activator.isTracingOn()) e.printStackTrace();
		}
	}

	protected void internalDisconnect(IProgressMonitor monitor) throws Exception
	{
		//TODO Will services like the sftp service be disconnected too? Or notified?
		Activator.trace("SshConnectorService.disconnect"); //$NON-NLS-1$
		try
		{
			if (session != null) {
				// Is disconnect being called because the network (connection) went down?
				boolean sessionLost = (fSessionLostHandler!=null && fSessionLostHandler.isSessionLost());
				// no more interested in handling session-lost, since we are disconnecting anyway
				fSessionLostHandler = null;
				// handle events
				if (sessionLost) {
					notifyError();
				}
				else {
					// Fire comm event to signal state about to change
					fireCommunicationsEvent(CommunicationsEvent.BEFORE_DISCONNECT);
				}
				sessionDisconnect();

				// Fire comm event to signal state changed
				notifyDisconnection();
				//TODO MOB - keep the session to avoid NPEs in services (disables gc for the session!)
				// session = null;
				// DKM - no need to clear uid cache
				clearPassword(false, true); // clear in-memory password
				//clearUserIdCache(); // Clear any cached local user IDs
			}
		}
		catch (Exception exc)
		{
			throw new java.lang.reflect.InvocationTargetException(exc);
		}
	}

	//TODO avoid having jsch type "Session" in the API.
	// Could be done by instantiating SshShellService and SshFileService here,
	//and implementing IShellService getShellService()
	//and IFileService getFileService().
	public Session getSession() {
		return session;
	}

	public String getControlEncoding() {
		//TODO this code should be in IHost
		String encoding = getHost().getDefaultEncoding(true);
		if (encoding==null) encoding = _defaultEncoding;
		//</code to be in IHost>
		return encoding;
	}

	/**
	 * Handle session-lost events.
	 * This is generic for any sort of connector service.
	 * Most of this is extracted from dstore's ConnectionStatusListener.
	 *
	 * TODO should be refactored to make it generally available, and allow
	 * dstore to derive from it.
	 */
	public static class SessionLostHandler implements Runnable, IRunnableWithProgress
	{
		private IConnectorService _connection;
		private boolean fSessionLost;

		public SessionLostHandler(IConnectorService cs)
		{
			_connection = cs;
			fSessionLost = false;
		}

		/**
		 * Notify that the connection has been lost. This may be called
		 * multiple times from multiple subsystems. The SessionLostHandler
		 * ensures that actual user feedback and disconnect actions are
		 * done only once, on the first invocation.
		 */
		public void sessionLost()
		{
			//avoid duplicate execution of sessionLost
			boolean showSessionLostDlg=false;
			synchronized(this) {
				if (!fSessionLost) {
					fSessionLost = true;
					showSessionLostDlg=true;
				}
			}
			if (showSessionLostDlg) {
				//invokes this.run() on dispatch thread
				Display.getDefault().asyncExec(this);
			}
		}

		public synchronized boolean isSessionLost() {
			return fSessionLost;
		}

		public void run()
		{
			Shell shell = getShell();
			//TODO need a more correct message for "session lost"
			//TODO allow users to reconnect from this dialog
			//SystemMessage msg = RSEUIPlugin.getPluginMessage(ISystemMessages.MSG_CONNECT_UNKNOWNHOST);

			SystemMessage msg = new SimpleSystemMessage(Activator.PLUGIN_ID,
					ICommonMessageIds.MSG_CONNECT_CANCELLED, IStatus.CANCEL,
					NLS.bind(CommonMessages.MSG_CONNECT_CANCELLED,
							_connection.getHost().getAliasName()));

			SystemMessageDialog dialog = new SystemMessageDialog(getShell(), msg);
			dialog.open();
			try
			{
				//TODO I think we should better use a Job for disconnecting?
				//But what about error messages?
				IRunnableContext runnableContext = getRunnableContext(getShell());
				// will do this.run(IProgressMonitor mon)
				//runnableContext.run(false,true,this); // inthread, cancellable, IRunnableWithProgress
				runnableContext.run(true,true,this); // fork, cancellable, IRunnableWithProgress
				_connection.reset();
				ISystemRegistry sr = RSECorePlugin.getTheSystemRegistry();
				sr.connectedStatusChange(_connection.getPrimarySubSystem(), false, true, true);
			}
			catch (InterruptedException exc) // user cancelled
			{
				if (shell != null)
					showDisconnectCancelledMessage(shell, _connection.getHostName(), _connection.getPort());
			}
			catch (java.lang.reflect.InvocationTargetException invokeExc) // unexpected error
			{
				Exception exc = (Exception)invokeExc.getTargetException();
				if (shell != null)
					showDisconnectErrorMessage(shell, _connection.getHostName(), _connection.getPort(), exc);
			}
			catch (Exception e)
			{
				SystemBasePlugin.logError(SshConnectorResources.SshConnectorService_ErrorDisconnecting, e);
			}
		}

		public void run(IProgressMonitor monitor)
		throws InvocationTargetException, InterruptedException
		{
			String message = null;
			message = SubSystemConfiguration.getDisconnectingMessage(
					_connection.getHostName(), _connection.getPort());
			monitor.beginTask(message, IProgressMonitor.UNKNOWN);
			try {
				_connection.disconnect(monitor);
			} catch (Exception exc) {
				if (exc instanceof java.lang.reflect.InvocationTargetException)
					throw (java.lang.reflect.InvocationTargetException) exc;
				if (exc instanceof java.lang.InterruptedException)
					throw (java.lang.InterruptedException) exc;
				throw new java.lang.reflect.InvocationTargetException(exc);
			} finally {
				monitor.done();
			}
		}

		public Shell getShell() {
			Shell activeShell = SystemBasePlugin.getActiveWorkbenchShell();
			if (activeShell != null) {
				return activeShell;
			}

			IWorkbenchWindow window = null;
			try {
				window = PlatformUI.getWorkbench().getActiveWorkbenchWindow();
			} catch (Exception e) {
				return null;
			}
			if (window == null) {
				IWorkbenchWindow[] windows = PlatformUI.getWorkbench()
				.getWorkbenchWindows();
				if (windows != null && windows.length > 0) {
					return windows[0].getShell();
				}
			} else {
				return window.getShell();
			}

			return null;
		}

		/**
		 * Get the progress monitor dialog for this operation. We try to use one
		 * for all phases of a single operation, such as connecting and
		 * resolving.
		 */
		protected IRunnableContext getRunnableContext(Shell rshell) {
			Shell shell = getShell();
			// for other cases, use statusbar
			IWorkbenchWindow win = SystemBasePlugin.getActiveWorkbenchWindow();
			if (win != null) {
				Shell winShell = RSEUIPlugin.getDefault().getWorkbench()
				.getActiveWorkbenchWindow().getShell();
				if (winShell != null && !winShell.isDisposed()
						&& winShell.isVisible()) {
					SystemBasePlugin
					.logInfo("Using active workbench window as runnable context"); //$NON-NLS-1$
					shell = winShell;
					return win;
				} else {
					win = null;
				}
			}
			if (shell == null || shell.isDisposed() || !shell.isVisible()) {
				SystemBasePlugin
				.logInfo("Using progress monitor dialog with given shell as parent"); //$NON-NLS-1$
				shell = rshell;
			}
			IRunnableContext dlg = new ProgressMonitorDialog(rshell);
			return dlg;
		}

		/**
		 * Show an error message when the disconnection fails. Shows a common
		 * message by default. Overridable.
		 */
		protected void showDisconnectErrorMessage(Shell shell, String hostName, int port, Exception exc)
		{
			//RSEUIPlugin.logError("Disconnect failed",exc); // temporary
			SystemMessage msg = new SimpleSystemMessage(Activator.PLUGIN_ID, ICommonMessageIds.MSG_DISCONNECT_FAILED, IStatus.ERROR, NLS.bind(CommonMessages.MSG_DISCONNECT_FAILED, hostName), exc);
			SystemMessageDialog msgDlg = new SystemMessageDialog(shell, msg);
			msgDlg.setException(exc);
			msgDlg.open();
		}

		/**
		 * Show an error message when the user cancels the disconnection.
		 * Shows a common message by default.
		 * Overridable.
		 */
		protected void showDisconnectCancelledMessage(Shell shell, String hostName, int port)
		{
			SystemMessage msg = new SimpleSystemMessage(Activator.PLUGIN_ID, ICommonMessageIds.MSG_DISCONNECT_CANCELLED, IStatus.CANCEL, NLS.bind(CommonMessages.MSG_DISCONNECT_CANCELLED, hostName));
			SystemMessageDialog msgDlg = new SystemMessageDialog(shell,msg);
			msgDlg.open();
		}
	}

	/**
	 * Notification from sub-services that our session was lost.
	 * Notify all subsystems properly.
	 * TODO allow user to try and reconnect?
	 */
	public void handleSessionLost() {
		Activator.trace("SshConnectorService: handleSessionLost"); //$NON-NLS-1$
		if (fSessionLostHandler!=null) {
			fSessionLostHandler.sessionLost();
		}
	}

	protected static Display getStandardDisplay() {
		Display display = Display.getCurrent();
		if( display==null ) {
			display = Display.getDefault();
		}
		return display;
	}

	private static class MyUserInfo implements UserInfo, UIKeyboardInteractive {
		private String fPassphrase;
		private String fPassword;
		private int fAttemptCount;
		private final String fUser;

		public MyUserInfo(String user, String password) {
			fUser = user;
			fPassword = password;
		}
		public String getPassword() {
			return fPassword;
		}
		public boolean promptYesNo(final String str) {
			//need to switch to UI thread for prompting
			final boolean[] retval = new boolean[1];
			getStandardDisplay().syncExec(new Runnable() {
				public void run() {
					retval[0] = MessageDialog.openQuestion(null, SshConnectorResources.SshConnectorService_Warning, str);
				}
			});
			return retval[0];
		}
		private String promptSecret(final String message) {
			final String[] retval = new String[1];
			final String finUser = fUser;
			getStandardDisplay().syncExec(new Runnable() {
				public void run() {
					UserValidationDialog uvd = new UserValidationDialog(null, null,
							finUser, message);
					uvd.setUsernameMutable(false);
					if (uvd.open() == Window.OK) {
						retval[0] = uvd.getPassword();
					} else {
						retval[0] = null;
					}
				}
			});
			return retval[0];
		}
		public String getPassphrase() {
			return fPassphrase;
		}
		public boolean promptPassphrase(String message) {
			fPassphrase = promptSecret(message);
			return (fPassphrase!=null);
		}
		public boolean promptPassword(final String message) {
			String _password = promptSecret(message);
			if (_password!=null) {
				fPassword=_password;
				return true;
			}
			return false;
		}
		public void showMessage(final String message) {
			getStandardDisplay().syncExec(new Runnable() {
				public void run() {
					MessageDialog.openInformation(null, SshConnectorResources.SshConnectorService_Info, message);
				}
			});
		}
		public String[] promptKeyboardInteractive(final String destination,
				final String name, final String instruction,
				final String[] prompt, final boolean[] echo)
		{
			if (prompt.length == 0) {
				// No need to prompt, just return an empty String array
				return new String[0];
			}
			try{
				if (fAttemptCount == 0 && fPassword != null && prompt.length == 1 && prompt[0].trim().equalsIgnoreCase("password:")) { //$NON-NLS-1$
					// Return the provided password the first time but always prompt on subsequent tries
					fAttemptCount++;
					return new String[] { fPassword };
				}
				final String[][] finResult = new String[1][];
				getStandardDisplay().syncExec(new Runnable() {
					public void run() {
						KeyboardInteractiveDialog dialog = new KeyboardInteractiveDialog(null,
								null, destination, name, instruction, prompt, echo);
						dialog.open();
						finResult[0]=dialog.getResult();
					}
				});
				String[] result=finResult[0];
				if (result == null)
					return null; // cancelled
				if (result.length == 1 && prompt.length == 1 && prompt[0].trim().equalsIgnoreCase("password:")) { //$NON-NLS-1$
					fPassword = result[0];
				}
				fAttemptCount++;
				return result;
			}
			catch(OperationCanceledException e){
				return null;
			}
		}
		/**
		 * Callback to indicate that a connection is about to be attempted
		 */
		public void aboutToConnect() {
			fAttemptCount = 0;
		}
		/**
		 * Callback to indicate that a connection was made
		 */
		public void connectionMade() {
			fAttemptCount = 0;
		}

	}

	public boolean isConnected() {
		if (session!=null) {
			if (session.isConnected()) {
				return true;
			} else if (fSessionLostHandler!=null) {
				Activator.trace("SshConnectorService.isConnected: false -> sessionLost"); //$NON-NLS-1$
				fSessionLostHandler.sessionLost();
			}
		}
		return false;
	}

	public boolean requiresPassword() {
		return false;
	}

	public boolean requiresUserId() {
		return false;
	}

	private IPropertySet getPropertySet()
	{
		IPropertySet propertySet = getPropertySet(PROPERTY_SET_SSH_SETTINGS);
		if(propertySet==null) {
			propertySet = createPropertySet(PROPERTY_SET_SSH_SETTINGS);
		}
		if (propertySet instanceof ILabeledObject) {
			//RSE 3.0 and later: translatable labels for properties
			String label = SshConnectorResources.SSH_SETTINGS_LABEL;
			((ILabeledObject)propertySet).setLabel(label);
		}

		//timeout
		IProperty p = propertySet.getProperty(PROPERTY_KEY_TIMEOUT);
		if (p==null) {
			p = propertySet.addProperty(PROPERTY_KEY_TIMEOUT, "0", PropertyType.getIntegerPropertyType()); //$NON-NLS-1$
		}
		p.setLabel(SshConnectorResources.PROPERTY_LABEL_TIMEOUT);

		//keepalive
		p = propertySet.getProperty(PROPERTY_KEY_KEEPALIVE);
		if (p==null) {
			p = propertySet.addProperty(PROPERTY_KEY_KEEPALIVE,"300", PropertyType.getIntegerPropertyType()); //$NON-NLS-1$
		}
		p.setLabel(SshConnectorResources.PROPERTY_LABEL_KEEPALIVE);
		return propertySet;
	}

}
