/**
 * Copyright 2019 Association for the promotion of open-source insurance software and for the establishment of open interface standards in the insurance industry (Verein zur Förderung quelloffener Versicherungssoftware und Etablierung offener Schnittstellenstandards in der Versicherungsbranche)
 *
 * 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.aposin.mergeprocessor.utils;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Vector;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.aposin.mergeprocessor.configuration.Configuration;
import org.aposin.mergeprocessor.configuration.IConfiguration;
import org.aposin.mergeprocessor.exception.SftpUtilException;
import org.aposin.mergeprocessor.model.IMergeUnit;
import org.aposin.mergeprocessor.model.MergeUnitException;
import org.aposin.mergeprocessor.model.MergeUnitStatus;
import org.aposin.mergeprocessor.model.git.GITMergeUnitFactory;
import org.aposin.mergeprocessor.model.svn.SVNMergeUnitFactory;
import org.eclipse.core.runtime.Path;

import com.jcraft.jsch.Channel;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.ChannelSftp.LsEntry;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpException;
import com.jcraft.jsch.UIKeyboardInteractive;
import com.jcraft.jsch.UserInfo;

/**
 *
 */
public class SftpUtil {

	private static final Logger LOGGER = Logger.getLogger(SftpUtil.class.getName());

	private static SftpUtil instance = null;

	private final IConfiguration configuration;
	private Session session = null;
	private ChannelSftp sftpChannel = null;

	private SftpUtil(IConfiguration configuration) {
		this.configuration = configuration;
	}

	/**
	 * @return the instance of the singleton Configuration.
	 */
	public static synchronized SftpUtil getInstance() {
		if (instance == null) {
			instance = new SftpUtil(E4CompatibilityUtil.getApplicationContext().get(IConfiguration.class));
		}
		return instance;
	}

	/**
	 * Deletes the given mergeunit
	 * 
	 * @param path
	 * @throws SftpUtilException
	 */
	public synchronized void deleteRemoteMergeUnit(String path) throws SftpUtilException {
		LogUtil.entering(path);
		connectIfNotConnected();

		try {
			sftpChannel.rm(path);
		} catch (SftpException e) {
			String message = String.format("Couldn't delete file=[%s].", path); //$NON-NLS-1$
			throw new SftpUtilException(message, e);
		}
		LogUtil.exiting();
	}

	/**
	 * @param mergeUnit
	 * @throws SftpUtilException
	 */
	public synchronized void copyMergeUnitToWork(IMergeUnit mergeUnit) throws SftpUtilException {
		LogUtil.entering(mergeUnit);
		connectIfNotConnected();

		String pathRemote = mergeUnit.getRemotePath();
		File fileLocal = new File(Configuration.getPathLocalMergeFile(mergeUnit));

		// delete a possibly existing file
		FileUtils.deleteQuietly(fileLocal);

		// create parent folder
		File fileLocalParent = fileLocal.getParentFile();
		if (!fileLocalParent.exists() && !fileLocalParent.mkdirs()) {
			String message = String.format("Couldn't create local folder. fileLocalParent=[%s].", //$NON-NLS-1$
					fileLocalParent.getAbsolutePath());
			throw LogUtil.throwing(new SftpUtilException(message));
		}

		try {
			// create file
			if (!fileLocal.createNewFile()) {
				String message = String.format("Couldn't create local file. fileLocal=[%s].", //$NON-NLS-1$
						fileLocal.getAbsolutePath());
				throw LogUtil.throwing(new SftpUtilException(message));
			}
			LOGGER.fine(
					() -> String.format("Copy from remote=%s to local=%s.", pathRemote, fileLocal.getAbsolutePath())); //$NON-NLS-1$

			try (final InputStream is = sftpChannel.get(pathRemote);
					final OutputStream outputStream = new FileOutputStream(fileLocal)) {
				IOUtils.copy(is, outputStream);
			}
		} catch (IOException | SftpException e) {
			String message = String.format("Couldn't copy remote=[%s] to local=[%s].", pathRemote, //$NON-NLS-1$
					fileLocal.getAbsolutePath());
			throw LogUtil.throwing(new SftpUtilException(message, e));
		}
		LogUtil.exiting();
	}

	/**
	 * @param mergeUnit
	 * @throws SftpUtilException
	 */
	public synchronized void copyMergeUnitFromWorkToDoneAndDeleteInTodo(IMergeUnit mergeUnit) throws SftpUtilException {
		LogUtil.entering(mergeUnit);
		String pathRemote = configuration.getSftpConfiguration().getDoneFolder() + mergeUnit.getFileName();
		copyMergeUnitFromWorkToRemote(mergeUnit, pathRemote);
		final String path;
		if (mergeUnit.getStatus() == MergeUnitStatus.CANCELLED) {
			path = configuration.getSftpConfiguration().getCanceledFolder() + mergeUnit.getFileName();
		} else {
			path = configuration.getSftpConfiguration().getTodoFolder() + mergeUnit.getFileName();
		}
		deleteRemoteMergeUnit(path);
		LogUtil.exiting();
	}

	/**
	 * 
	 * @param mergeUnit
	 * @throws SftpUtilException
	 */
	public synchronized void moveMergeUnitFromRemoteToIgnore(IMergeUnit mergeUnit) throws SftpUtilException {
		LogUtil.entering(mergeUnit);
		String target = configuration.getSftpConfiguration().getIgnoredFolder() + mergeUnit.getFileName();

		moveMergeUnit(mergeUnit, target);
		mergeUnit.setStatus(MergeUnitStatus.IGNORED);
		LogUtil.exiting();
	}

	/**
	 * 
	 * @param mergeUnit
	 * @throws SftpUtilException
	 */
	public synchronized void moveMergeUnitFromRemoteToCanceled(IMergeUnit mergeUnit) throws SftpUtilException {
		LogUtil.entering(mergeUnit);
		String target = configuration.getSftpConfiguration().getCanceledFolder() + mergeUnit.getFileName();

		moveMergeUnit(mergeUnit, target);
		mergeUnit.setStatus(MergeUnitStatus.CANCELLED);
		LogUtil.exiting();
	}

	/**
	 * 
	 * @param mergeUnit
	 * @throws SftpUtilException
	 */
	public synchronized void moveMergeUnitFromRemoteToDone(IMergeUnit mergeUnit) throws SftpUtilException {
		LogUtil.entering(mergeUnit);
		String target = configuration.getSftpConfiguration().getDoneFolder() + mergeUnit.getFileName();

		moveMergeUnit(mergeUnit, target);
		mergeUnit.setStatus(MergeUnitStatus.DONE);
		LogUtil.exiting();
	}

	/**
	 * 
	 * @param mergeUnit
	 * @throws SftpUtilException
	 */
	public synchronized void moveMergeUnitFromRemoteToManual(IMergeUnit mergeUnit) throws SftpUtilException {
		LogUtil.entering(mergeUnit);
		String target = configuration.getSftpConfiguration().getManualFolder() + mergeUnit.getFileName();

		moveMergeUnit(mergeUnit, target);
		mergeUnit.setStatus(MergeUnitStatus.MANUAL);
		LogUtil.exiting();
	}

	/**
	 * 
	 * @param mergeUnit
	 * @throws SftpUtilException
	 */
	public synchronized void moveMergeUnitFromRemoteToTodo(IMergeUnit mergeUnit) throws SftpUtilException {
		LogUtil.entering(mergeUnit);
		String target = configuration.getSftpConfiguration().getTodoFolder() + mergeUnit.getFileName();
		moveMergeUnit(mergeUnit, target);
		mergeUnit.setStatus(MergeUnitStatus.TODO);
		LogUtil.exiting();
	}

	private void moveMergeUnit(IMergeUnit mergeUnit, String target) throws SftpUtilException {
		LogUtil.entering(mergeUnit, target);
		String source = mergeUnit.getRemotePath();

		if (source.equals(target)) {
			LOGGER.fine(
					() -> String.format("Source=%s and target=%s are the same. Nothing to do here...", source, target)); //$NON-NLS-1$
		} else {
			LOGGER.fine(() -> String.format("Moving mergeUnit=%s from %s to %s.", mergeUnit, source, target)); //$NON-NLS-1$
			try {
				try {
					sftpChannel.ls(target.replace('/' + mergeUnit.getFileName(), ""));
				} catch (SftpException e) {
					sftpChannel.mkdir(target.replace('/' + mergeUnit.getFileName(), ""));
				}

				sftpChannel.rename(source, target);
				mergeUnit.setRemotePath(target);
			} catch (SftpException e) {
				String message = String.format("Couldn't move mergeUnit=[%s] from source=[%s] to target=[%s].", //$NON-NLS-1$
						mergeUnit, source, target);
				throw LogUtil.throwing(new SftpUtilException(message, e));
			}
		}
		LogUtil.exiting();
	}

	/**
	 * @param mergeunit
	 * @param pathRemote
	 * @throws SftpUtilException
	 */
	private void copyMergeUnitFromWorkToRemote(IMergeUnit mergeUnit, String pathRemote) throws SftpUtilException {
		LogUtil.entering(mergeUnit, pathRemote);
		connectIfNotConnected();
		File fileLocal = new File(Configuration.getPathLocalMergeFile(mergeUnit));
		File fileLocalParent = fileLocal.getParentFile();
		if (!fileLocalParent.exists() && !fileLocalParent.mkdirs()) {
			String message = String.format("Couldn't create local folder. fileLocalParent=[%s].", //$NON-NLS-1$
					fileLocalParent.getAbsolutePath());
			throw LogUtil.throwing(new SftpUtilException(message));
		}

		LOGGER.info(() -> String.format("Copy from local=%s to remote=%s.", fileLocal.getAbsolutePath(), pathRemote)); //$NON-NLS-1$

		try (final InputStream is = new FileInputStream(fileLocal);
				final OutputStream outputStream = sftpChannel.put(pathRemote)) {
			IOUtils.copy(is, outputStream);
		} catch (IOException | SftpException e) {
			String message = String.format("Couldn't copy local=[%s] to remote=[%s].", fileLocal.getAbsolutePath(), pathRemote); //$NON-NLS-1$
			throw new SftpUtilException(message, e);
		}

		mergeUnit.setRemotePath(pathRemote);
		LogUtil.exiting();
	}

	/**
	 * @return the parsed todo files on the sftp server
	 * @throws SftpUtilException
	 */
	public synchronized List<IMergeUnit> getMergeUnitsTodo() throws SftpUtilException {
		LogUtil.entering();
		List<IMergeUnit> mergeUnitsTodo = getMergeUnitsFromFolder(configuration.getSftpConfiguration().getTodoFolder());
		return LogUtil.exiting(mergeUnitsTodo);
	}

	/**
	 * @return the parsed done files on the sftp server
	 * @throws SftpUtilException
	 */
	public synchronized List<IMergeUnit> getMergeUnitsDone() throws SftpUtilException {
		LogUtil.entering();
		List<IMergeUnit> mergeUnitsDone = getMergeUnitsFromFolder(configuration.getSftpConfiguration().getDoneFolder());
		return LogUtil.exiting(mergeUnitsDone);
	}

	/**
	 * @return the parsed ignored files on the sftp server
	 * @throws SftpUtilException
	 */
	public synchronized List<IMergeUnit> getMergeUnitsIgnored() throws SftpUtilException {
		LogUtil.entering();
		List<IMergeUnit> mergeUnitsIgnored = getMergeUnitsFromFolder(
				configuration.getSftpConfiguration().getIgnoredFolder());
		return LogUtil.exiting(mergeUnitsIgnored);
	}

	/**
	 * @return the parsed canceled files on the sftp server
	 * @throws SftpUtilException
	 */
	public synchronized List<IMergeUnit> getMergeUnitsCanceled() throws SftpUtilException {
		LogUtil.entering();
		List<IMergeUnit> mergeUnitsCanceled = getMergeUnitsFromFolder(
				configuration.getSftpConfiguration().getCanceledFolder());
		return LogUtil.exiting(mergeUnitsCanceled);
	}

	public synchronized List<IMergeUnit> getMergeUnitsManual() throws SftpUtilException {
		LogUtil.entering();
		List<IMergeUnit> mergeUnitsManual = getMergeUnitsFromFolder(
				configuration.getSftpConfiguration().getManualFolder());
		return LogUtil.exiting(mergeUnitsManual);
	}

	public InputStream createInputStream(final String path) throws SftpException {
		return sftpChannel.get(path);
	}

	/**
	 * Writes a given {@link String} to a remote {@link Path}. It overwrites any
	 * existing content.
	 * 
	 * @param content the string to write
	 * @param path    the remote {@link Path}
	 * @throws SftpException
	 * @throws IOException
	 * @throws SftpUtilException
	 */
	public void writeToRemotePath(final String content, final String path)
			throws SftpException, IOException, SftpUtilException {
		connectIfNotConnected();
		try (final InputStream is = IOUtils.toInputStream(content, StandardCharsets.UTF_8)) {
			sftpChannel.put(is, path);
		}
	}

	@SuppressWarnings("unchecked")
	private List<IMergeUnit> getMergeUnitsFromFolder(String pathFolder) throws SftpUtilException {
		LogUtil.entering(pathFolder);

		connectIfNotConnected();

		Vector<LsEntry> files = null;

		List<IMergeUnit> mergeunits = new ArrayList<>();

		try {
			LOGGER.fine(() -> String.format("List files from remote=%s.", pathFolder)); //$NON-NLS-1$
			files = sftpChannel.ls(pathFolder);
		} catch (SftpException e) {
			if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) {
				// Directory does not exist, let's create it
				LOGGER.log(Level.INFO, "File does not exist.", e);
				try {
					sftpChannel.mkdir(pathFolder);
				} catch (SftpException e1) {
					LOGGER.log(Level.SEVERE, "Could not create directory.", e1);
				}
			}
			return mergeunits;
		}

		// filter directory entries '.' and '..'
		files.removeIf(file -> file.getFilename().equals(".") || file.getFilename().equals("..")); //$NON-NLS-1$ //$NON-NLS-2$

		try {
			if (LOGGER.isLoggable(Level.FINEST)) {
				LOGGER.finest(String.format("files.size=%s.", files.size())); //$NON-NLS-1$
			}
			for (LsEntry file : files) {
				handleMergeFile(pathFolder, mergeunits, file);
			}
		} catch (SftpException | MergeUnitException e) {
			throw LogUtil.throwing(new SftpUtilException("Caught Exception while parsing files from sftp server.", e)); //$NON-NLS-1$
		}

		return LogUtil.exiting(mergeunits);
	}

	private void handleMergeFile(String pathFolder, List<IMergeUnit> mergeunits, LsEntry file)
			throws MergeUnitException, SftpException, SftpUtilException {
		String fileName = file.getFilename();
		String attributes = file.getLongname();
		String path = pathFolder + fileName;

		LOGGER.fine(() -> String.format("Getting file fileName=%s, path=%s, attributes=%s", fileName, path, //$NON-NLS-1$
				attributes));
		try (InputStream is = sftpChannel.get(path)) {

			IMergeUnit mergeunit = null;
			if (fileName.endsWith(Configuration.EXTENSION_PLAINMERGE_FILE)
					|| fileName.endsWith(Configuration.SVN_EXTENSION_FILE)
					|| fileName.endsWith(Configuration.SVN_PACKAGE_MERGE_EXTENSION_FILE)) {
				LOGGER.fine(() -> String.format("Parsing SVN merge file %s.", path)); //$NON-NLS-1$
				mergeunit = SVNMergeUnitFactory.createMergeUnitFromPlainMergeFile(configuration, path, fileName,
						is);
			} else if (fileName.endsWith(Configuration.GIT_EXTENSION_FILE)) {
				LOGGER.fine(() -> String.format("Parsing GIT merge file %s.", path)); //$NON-NLS-1$
				mergeunit = GITMergeUnitFactory.create(configuration, Paths.get(path), is);
			} else {
				LOGGER.info(() -> String.format("Skipping file fileName=%s, path=%s, attributes=%s", //$NON-NLS-1$
						fileName, path, attributes));
				return;
			}
			mergeunits.add(mergeunit);
		} catch (IOException e) {
			String message = String.format("Caught exception while parsing merge unit from path=[%s].", path); //$NON-NLS-1$
			throw LogUtil.throwing(new SftpUtilException(message, e));
		}
	}

	private void connectIfNotConnected() throws SftpUtilException {
		LogUtil.entering();
		if (sftpChannel == null || !sftpChannel.isConnected()) {
			connect();
		}
		LogUtil.exiting();
	}

	private void connect() throws SftpUtilException {
		LogUtil.entering();

		if (sftpChannel != null) {
			LOGGER.fine("First close old connection."); //$NON-NLS-1$
			disconnect();
		}

		String host = configuration.getSftpConfiguration().getHost();
		String user = configuration.getSftpConfiguration().getUser();
		String password = configuration.getSftpConfiguration().getPassword();
		String workingFolder = Configuration.getPathSftpWorkingFolder();
		String knownHosts = workingFolder + "known_hosts"; //$NON-NLS-1$

		File fWorkingFolder = new File(workingFolder);

		if (!fWorkingFolder.exists()) {
			fWorkingFolder.mkdirs();
		}

		try {
			JSch jsch = new JSch();
			jsch.setKnownHosts(knownHosts);

			session = jsch.getSession(user, host);
			session.setPassword(password);
			// "interactive" version
			session.setUserInfo(new SftpUserInfo(configuration, password));
			session.connect();
			LOGGER.fine("session is connected."); //$NON-NLS-1$

			Channel channel = session.openChannel("sftp"); //$NON-NLS-1$
			channel.connect();
			LOGGER.fine("channel is connected."); //$NON-NLS-1$

			sftpChannel = (ChannelSftp) channel;
			LOGGER.info("sftpChannel is set."); //$NON-NLS-1$
		} catch (JSchException e) {
			disconnect();
			throw LogUtil.throwing(new SftpUtilException("Couldn't connect to sftp server.", e)); //$NON-NLS-1$
		}

		LogUtil.exiting();
	}

	/**
	 * Closes all open connections.
	 */
	public synchronized void disconnect() {
		LogUtil.entering();
		if (sftpChannel != null && sftpChannel.isConnected()) {
			LOGGER.fine("Exiting channel."); //$NON-NLS-1$
			sftpChannel.exit();
			sftpChannel = null;
		}
		if (session != null && session.isConnected()) {
			LOGGER.fine("Disconnecting session."); //$NON-NLS-1$
			session.disconnect();
			session = null;
		}
		LogUtil.exiting();
	}

	/**
	 * Returns the script of the merge unit as a {@link String}.
	 * 
	 * @param mergeUnit the merge unit
	 * @return the script as a {@link String}
	 * @throws SftpUtilException
	 */
	public String getContent(IMergeUnit mergeUnit) throws SftpUtilException {
		LogUtil.entering(mergeUnit);
		connectIfNotConnected();
		final String pathRemote = mergeUnit.getRemotePath();
		try (InputStream is = sftpChannel.get(pathRemote)) {
			return LogUtil.exiting(IOUtils.toString(is, StandardCharsets.UTF_8));
		} catch (IOException | SftpException e) {
			String message = String.format("Couldn't read remote=[%s].", pathRemote); //$NON-NLS-1$
			throw LogUtil.throwing(new SftpUtilException(message, e));
		}
	}

	private static class SftpUserInfo implements UserInfo, UIKeyboardInteractive {

		private final IConfiguration configuration;
		private String password;

		private SftpUserInfo(IConfiguration configuration, String password) {
			this.configuration = configuration;
			this.password = password;
		}

		@Override
		public synchronized void showMessage(String message) {
			LOGGER.info(message);
		}

		@Override
		public synchronized boolean promptYesNo(String message) {
			LOGGER.info(message);
			// We always trust our connections
			return true;
		}

		@Override
		public synchronized boolean promptPassword(String message) {
			LOGGER.info(message);
			return false;
		}

		@Override
		public synchronized boolean promptPassphrase(String message) {
			LOGGER.info(message);
			return false;
		}

		@Override
		public synchronized String getPassword() {
			return password;
		}

		@Override
		public synchronized String getPassphrase() {
			return null;
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public synchronized String[] promptKeyboardInteractive(String destination, String name, String instruction,
				String[] prompt, boolean[] echo) {
			LogUtil.entering(destination, name, instruction, prompt, echo);
			String[] retVal = new String[prompt.length];

			if (destination.equals(configuration.getSftpConfiguration().getUser() + "@" //$NON-NLS-1$
					+ configuration.getSftpConfiguration().getHost())) {
				for (int i = 0; i < prompt.length; i++) {
					if (prompt[i].equals("Password: ")) { //$NON-NLS-1$
						retVal[i] = password;
					} else {
						retVal[i] = null;
					}
				}
			}

			return LogUtil.exiting(retVal);
		}
	}
}