/*
 * Copyright (C) 2008, 2014, Google Inc.
 * and other copyright owners as documented in the project's IP log.
 *
 * This program and the accompanying materials are made available
 * under the terms of the Eclipse Distribution License v1.0 which
 * accompanies this distribution, is reproduced below, and is
 * available at http://www.eclipse.org/org/documents/edl-v10.php
 *
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or
 * without modification, are permitted provided that the following
 * conditions are met:
 *
 * - Redistributions of source code must retain the above copyright
 *   notice, this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above
 *   copyright notice, this list of conditions and the following
 *   disclaimer in the documentation and/or other materials provided
 *   with the distribution.
 *
 * - Neither the name of the Eclipse Foundation, Inc. nor the
 *   names of its contributors may be used to endorse or promote
 *   products derived from this software without specific prior
 *   written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
 * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
 * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package org.eclipse.jgit.transport;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.eclipse.jgit.errors.InvalidPatternException;
import org.eclipse.jgit.fnmatch.FileNameMatcher;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.util.FS;
import org.eclipse.jgit.util.StringUtils;

/**
 * Simple configuration parser for the OpenSSH ~/.ssh/config file.
 * <p>
 * Since JSch does not (currently) have the ability to parse an OpenSSH
 * configuration file this is a simple parser to read that file and make the
 * critical options available to {@link SshSessionFactory}.
 */
public class OpenSshConfig {
	/** IANA assigned port number for SSH. */
	static final int SSH_PORT = 22;

	/**
	 * Obtain the user's configuration data.
	 * <p>
	 * The configuration file is always returned to the caller, even if no file
	 * exists in the user's home directory at the time the call was made. Lookup
	 * requests are cached and are automatically updated if the user modifies
	 * the configuration file since the last time it was cached.
	 *
	 * @param fs
	 *            the file system abstraction which will be necessary to
	 *            perform certain file system operations.
	 * @return a caching reader of the user's configuration file.
	 */
	public static OpenSshConfig get(FS fs) {
		File home = fs.userHome();
		if (home == null)
			home = new File(".").getAbsoluteFile(); //$NON-NLS-1$

		final File config = new File(new File(home, ".ssh"), Constants.CONFIG); //$NON-NLS-1$
		final OpenSshConfig osc = new OpenSshConfig(home, config);
		osc.refresh();
		return osc;
	}

	/** The user's home directory, as key files may be relative to here. */
	private final File home;

	/** The .ssh/config file we read and monitor for updates. */
	private final File configFile;

	/** Modification time of {@link #configFile} when {@link #hosts} loaded. */
	private long lastModified;

	/** Cached entries read out of the configuration file. */
	private Map<String, Host> hosts;

	OpenSshConfig(final File h, final File cfg) {
		home = h;
		configFile = cfg;
		hosts = Collections.emptyMap();
	}

	/**
	 * Locate the configuration for a specific host request.
	 *
	 * @param hostName
	 *            the name the user has supplied to the SSH tool. This may be a
	 *            real host name, or it may just be a "Host" block in the
	 *            configuration file.
	 * @return r configuration for the requested name. Never null.
	 */
	public Host lookup(final String hostName) {
		final Map<String, Host> cache = refresh();
		Host h = cache.get(hostName);
		if (h == null)
			h = new Host();
		if (h.patternsApplied)
			return h;

		for (final Map.Entry<String, Host> e : cache.entrySet()) {
			if (!isHostPattern(e.getKey()))
				continue;
			if (!isHostMatch(e.getKey(), hostName))
				continue;
			h.copyFrom(e.getValue());
		}

		if (h.hostName == null)
			h.hostName = hostName;
		if (h.user == null)
			h.user = OpenSshConfig.userName();
		if (h.port == 0)
			h.port = OpenSshConfig.SSH_PORT;
		if (h.connectionAttempts == 0)
			h.connectionAttempts = 1;
		h.patternsApplied = true;
		return h;
	}

	private synchronized Map<String, Host> refresh() {
		final long mtime = configFile.lastModified();
		if (mtime != lastModified) {
			try {
				final FileInputStream in = new FileInputStream(configFile);
				try {
					hosts = parse(in);
				} finally {
					in.close();
				}
			} catch (FileNotFoundException none) {
				hosts = Collections.emptyMap();
			} catch (IOException err) {
				hosts = Collections.emptyMap();
			}
			lastModified = mtime;
		}
		return hosts;
	}

	private Map<String, Host> parse(final InputStream in) throws IOException {
		final Map<String, Host> m = new LinkedHashMap<>();
		final BufferedReader br = new BufferedReader(new InputStreamReader(in));
		final List<Host> current = new ArrayList<>(4);
		String line;

		while ((line = br.readLine()) != null) {
			line = line.trim();
			if (line.length() == 0 || line.startsWith("#")) //$NON-NLS-1$
				continue;

			final String[] parts = line.split("[ \t]*[= \t]", 2); //$NON-NLS-1$
			final String keyword = parts[0].trim();
			final String argValue = parts[1].trim();

			if (StringUtils.equalsIgnoreCase("Host", keyword)) { //$NON-NLS-1$
				current.clear();
				for (final String pattern : argValue.split("[ \t]")) { //$NON-NLS-1$
					final String name = dequote(pattern);
					Host c = m.get(name);
					if (c == null) {
						c = new Host();
						m.put(name, c);
					}
					current.add(c);
				}
				continue;
			}

			if (current.isEmpty()) {
				// We received an option outside of a Host block. We
				// don't know who this should match against, so skip.
				//
				continue;
			}

			if (StringUtils.equalsIgnoreCase("HostName", keyword)) { //$NON-NLS-1$
				for (final Host c : current)
					if (c.hostName == null)
						c.hostName = dequote(argValue);
			} else if (StringUtils.equalsIgnoreCase("User", keyword)) { //$NON-NLS-1$
				for (final Host c : current)
					if (c.user == null)
						c.user = dequote(argValue);
			} else if (StringUtils.equalsIgnoreCase("Port", keyword)) { //$NON-NLS-1$
				try {
					final int port = Integer.parseInt(dequote(argValue));
					for (final Host c : current)
						if (c.port == 0)
							c.port = port;
				} catch (NumberFormatException nfe) {
					// Bad port number. Don't set it.
				}
			} else if (StringUtils.equalsIgnoreCase("IdentityFile", keyword)) { //$NON-NLS-1$
				for (final Host c : current)
					if (c.identityFile == null)
						c.identityFile = toFile(dequote(argValue));
			} else if (StringUtils.equalsIgnoreCase(
					"PreferredAuthentications", keyword)) { //$NON-NLS-1$
				for (final Host c : current)
					if (c.preferredAuthentications == null)
						c.preferredAuthentications = nows(dequote(argValue));
			} else if (StringUtils.equalsIgnoreCase("BatchMode", keyword)) { //$NON-NLS-1$
				for (final Host c : current)
					if (c.batchMode == null)
						c.batchMode = yesno(dequote(argValue));
			} else if (StringUtils.equalsIgnoreCase(
					"StrictHostKeyChecking", keyword)) { //$NON-NLS-1$
				String value = dequote(argValue);
				for (final Host c : current)
					if (c.strictHostKeyChecking == null)
						c.strictHostKeyChecking = value;
			} else if (StringUtils.equalsIgnoreCase(
					"ConnectionAttempts", keyword)) { //$NON-NLS-1$
				try {
					final int connectionAttempts = Integer.parseInt(dequote(argValue));
					if (connectionAttempts > 0) {
						for (final Host c : current)
							if (c.connectionAttempts == 0)
								c.connectionAttempts = connectionAttempts;
					}
				} catch (NumberFormatException nfe) {
					// ignore bad values
				}
			}
		}

		return m;
	}

	private static boolean isHostPattern(final String s) {
		return s.indexOf('*') >= 0 || s.indexOf('?') >= 0;
	}

	private static boolean isHostMatch(final String pattern, final String name) {
		final FileNameMatcher fn;
		try {
			fn = new FileNameMatcher(pattern, null);
		} catch (InvalidPatternException e) {
			return false;
		}
		fn.append(name);
		return fn.isMatch();
	}

	private static String dequote(final String value) {
		if (value.startsWith("\"") && value.endsWith("\"")) //$NON-NLS-1$ //$NON-NLS-2$
			return value.substring(1, value.length() - 1);
		return value;
	}

	private static String nows(final String value) {
		final StringBuilder b = new StringBuilder();
		for (int i = 0; i < value.length(); i++) {
			if (!Character.isSpaceChar(value.charAt(i)))
				b.append(value.charAt(i));
		}
		return b.toString();
	}

	private static Boolean yesno(final String value) {
		if (StringUtils.equalsIgnoreCase("yes", value)) //$NON-NLS-1$
			return Boolean.TRUE;
		return Boolean.FALSE;
	}

	private File toFile(final String path) {
		if (path.startsWith("~/")) //$NON-NLS-1$
			return new File(home, path.substring(2));
		File ret = new File(path);
		if (ret.isAbsolute())
			return ret;
		return new File(home, path);
	}

	static String userName() {
		return AccessController.doPrivileged(new PrivilegedAction<String>() {
			@Override
			public String run() {
				return System.getProperty("user.name"); //$NON-NLS-1$
			}
		});
	}

	/**
	 * Configuration of one "Host" block in the configuration file.
	 * <p>
	 * If returned from {@link OpenSshConfig#lookup(String)} some or all of the
	 * properties may not be populated. The properties which are not populated
	 * should be defaulted by the caller.
	 * <p>
	 * When returned from {@link OpenSshConfig#lookup(String)} any wildcard
	 * entries which appear later in the configuration file will have been
	 * already merged into this block.
	 */
	public static class Host {
		boolean patternsApplied;

		String hostName;

		int port;

		File identityFile;

		String user;

		String preferredAuthentications;

		Boolean batchMode;

		String strictHostKeyChecking;

		int connectionAttempts;

		void copyFrom(final Host src) {
			if (hostName == null)
				hostName = src.hostName;
			if (port == 0)
				port = src.port;
			if (identityFile == null)
				identityFile = src.identityFile;
			if (user == null)
				user = src.user;
			if (preferredAuthentications == null)
				preferredAuthentications = src.preferredAuthentications;
			if (batchMode == null)
				batchMode = src.batchMode;
			if (strictHostKeyChecking == null)
				strictHostKeyChecking = src.strictHostKeyChecking;
			if (connectionAttempts == 0)
				connectionAttempts = src.connectionAttempts;
		}

		/**
		 * @return the value StrictHostKeyChecking property, the valid values
		 *         are "yes" (unknown hosts are not accepted), "no" (unknown
		 *         hosts are always accepted), and "ask" (user should be asked
		 *         before accepting the host)
		 */
		public String getStrictHostKeyChecking() {
			return strictHostKeyChecking;
		}

		/**
		 * @return the real IP address or host name to connect to; never null.
		 */
		public String getHostName() {
			return hostName;
		}

		/**
		 * @return the real port number to connect to; never 0.
		 */
		public int getPort() {
			return port;
		}

		/**
		 * @return path of the private key file to use for authentication; null
		 *         if the caller should use default authentication strategies.
		 */
		public File getIdentityFile() {
			return identityFile;
		}

		/**
		 * @return the real user name to connect as; never null.
		 */
		public String getUser() {
			return user;
		}

		/**
		 * @return the preferred authentication methods, separated by commas if
		 *         more than one authentication method is preferred.
		 */
		public String getPreferredAuthentications() {
			return preferredAuthentications;
		}

		/**
		 * @return true if batch (non-interactive) mode is preferred for this
		 *         host connection.
		 */
		public boolean isBatchMode() {
			return batchMode != null && batchMode.booleanValue();
		}

		/**
		 * @return the number of tries (one per second) to connect before
		 *         exiting. The argument must be an integer. This may be useful
		 *         in scripts if the connection sometimes fails. The default is
		 *         1.
		 * @since 3.4
		 */
		public int getConnectionAttempts() {
			return connectionAttempts;
		}
	}
}
