/*
 * Copyright 2012-2017 the original author or authors.
 *
 * 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.springframework.boot.loader.jar;

import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.FilePermission;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.net.URLStreamHandler;
import java.security.Permission;

import org.springframework.boot.loader.data.RandomAccessData.ResourceAccess;

/**
 * {@link java.net.JarURLConnection} used to support {@link JarFile#getUrl()}.
 *
 * @author Phillip Webb
 * @author Andy Wilkinson
 */
final class JarURLConnection extends java.net.JarURLConnection {

	private static ThreadLocal<Boolean> useFastExceptions = new ThreadLocal<>();

	private static final FileNotFoundException FILE_NOT_FOUND_EXCEPTION = new FileNotFoundException(
			"Jar file or entry not found");

	private static final IllegalStateException NOT_FOUND_CONNECTION_EXCEPTION = new IllegalStateException(
			FILE_NOT_FOUND_EXCEPTION);

	private static final String SEPARATOR = "!/";

	private static final URL EMPTY_JAR_URL;

	static {
		try {
			EMPTY_JAR_URL = new URL("jar:", null, 0, "file:!/", new URLStreamHandler() {
				@Override
				protected URLConnection openConnection(URL u) throws IOException {
					// Stub URLStreamHandler to prevent the wrong JAR Handler from being
					// Instantiated and cached.
					return null;
				}
			});
		}
		catch (MalformedURLException ex) {
			throw new IllegalStateException(ex);
		}
	}

	private static final JarEntryName EMPTY_JAR_ENTRY_NAME = new JarEntryName("");

	private static final String READ_ACTION = "read";

	private static final JarURLConnection NOT_FOUND_CONNECTION = JarURLConnection
			.notFound();

	private final JarFile jarFile;

	private Permission permission;

	private URL jarFileUrl;

	private final JarEntryName jarEntryName;

	private JarEntry jarEntry;

	private JarURLConnection(URL url, JarFile jarFile, JarEntryName jarEntryName)
			throws IOException {
		// What we pass to super is ultimately ignored
		super(EMPTY_JAR_URL);
		this.url = url;
		this.jarFile = jarFile;
		this.jarEntryName = jarEntryName;
	}

	@Override
	public void connect() throws IOException {
		if (this.jarFile == null) {
			throw FILE_NOT_FOUND_EXCEPTION;
		}
		if (!this.jarEntryName.isEmpty() && this.jarEntry == null) {
			this.jarEntry = this.jarFile.getJarEntry(getEntryName());
			if (this.jarEntry == null) {
				throwFileNotFound(this.jarEntryName, this.jarFile);
			}
		}
		this.connected = true;
	}

	@Override
	public JarFile getJarFile() throws IOException {
		connect();
		return this.jarFile;
	}

	@Override
	public URL getJarFileURL() {
		if (this.jarFile == null) {
			throw NOT_FOUND_CONNECTION_EXCEPTION;
		}
		if (this.jarFileUrl == null) {
			this.jarFileUrl = buildJarFileUrl();
		}
		return this.jarFileUrl;
	}

	private URL buildJarFileUrl() {
		try {
			String spec = this.jarFile.getUrl().getFile();
			if (spec.endsWith(SEPARATOR)) {
				spec = spec.substring(0, spec.length() - SEPARATOR.length());
			}
			if (spec.indexOf(SEPARATOR) == -1) {
				return new URL(spec);
			}
			return new URL("jar:" + spec);
		}
		catch (MalformedURLException ex) {
			throw new IllegalStateException(ex);
		}
	}

	@Override
	public JarEntry getJarEntry() throws IOException {
		if (this.jarEntryName == null || this.jarEntryName.isEmpty()) {
			return null;
		}
		connect();
		return this.jarEntry;
	}

	@Override
	public String getEntryName() {
		if (this.jarFile == null) {
			throw NOT_FOUND_CONNECTION_EXCEPTION;
		}
		return this.jarEntryName.toString();
	}

	@Override
	public InputStream getInputStream() throws IOException {
		if (this.jarFile == null) {
			throw FILE_NOT_FOUND_EXCEPTION;
		}
		if (this.jarEntryName.isEmpty()
				&& this.jarFile.getType() == JarFile.JarFileType.DIRECT) {
			throw new IOException("no entry name specified");
		}
		connect();
		InputStream inputStream = (this.jarEntryName.isEmpty()
				? this.jarFile.getData().getInputStream(ResourceAccess.ONCE)
				: this.jarFile.getInputStream(this.jarEntry));
		if (inputStream == null) {
			throwFileNotFound(this.jarEntryName, this.jarFile);
		}
		return inputStream;
	}

	private void throwFileNotFound(Object entry, JarFile jarFile)
			throws FileNotFoundException {
		if (Boolean.TRUE.equals(useFastExceptions.get())) {
			throw FILE_NOT_FOUND_EXCEPTION;
		}
		throw new FileNotFoundException(
				"JAR entry " + entry + " not found in " + jarFile.getName());
	}

	@Override
	public int getContentLength() {
		if (this.jarFile == null) {
			return -1;
		}
		try {
			if (this.jarEntryName.isEmpty()) {
				return this.jarFile.size();
			}
			JarEntry entry = getJarEntry();
			return (entry == null ? -1 : (int) entry.getSize());
		}
		catch (IOException ex) {
			return -1;
		}
	}

	@Override
	public Object getContent() throws IOException {
		connect();
		return (this.jarEntryName.isEmpty() ? this.jarFile : super.getContent());
	}

	@Override
	public String getContentType() {
		return (this.jarEntryName == null ? null : this.jarEntryName.getContentType());
	}

	@Override
	public Permission getPermission() throws IOException {
		if (this.jarFile == null) {
			throw FILE_NOT_FOUND_EXCEPTION;
		}
		if (this.permission == null) {
			this.permission = new FilePermission(
					this.jarFile.getRootJarFile().getFile().getPath(), READ_ACTION);
		}
		return this.permission;
	}

	static void setUseFastExceptions(boolean useFastExceptions) {
		JarURLConnection.useFastExceptions.set(useFastExceptions);
	}

	static JarURLConnection get(URL url, JarFile jarFile) throws IOException {
		String spec = extractFullSpec(url, jarFile.getPathFromRoot());
		int separator;
		int index = 0;
		while ((separator = spec.indexOf(SEPARATOR, index)) > 0) {
			String entryName = spec.substring(index, separator);
			JarEntry jarEntry = jarFile.getJarEntry(entryName);
			if (jarEntry == null) {
				return JarURLConnection.notFound(jarFile, JarEntryName.get(entryName));
			}
			jarFile = jarFile.getNestedJarFile(jarEntry);
			index += separator + SEPARATOR.length();
		}
		JarEntryName jarEntryName = JarEntryName.get(spec, index);
		if (Boolean.TRUE.equals(useFastExceptions.get())) {
			if (!jarEntryName.isEmpty()
					&& !jarFile.containsEntry(jarEntryName.toString())) {
				return NOT_FOUND_CONNECTION;
			}
		}
		return new JarURLConnection(url, jarFile, jarEntryName);
	}

	private static String extractFullSpec(URL url, String pathFromRoot) {
		String file = url.getFile();
		int separatorIndex = file.indexOf(SEPARATOR);
		if (separatorIndex < 0) {
			return "";
		}
		int specIndex = separatorIndex + SEPARATOR.length() + pathFromRoot.length();
		return file.substring(specIndex);
	}

	private static JarURLConnection notFound() {
		try {
			return notFound(null, null);
		}
		catch (IOException ex) {
			throw new IllegalStateException(ex);
		}
	}

	private static JarURLConnection notFound(JarFile jarFile, JarEntryName jarEntryName)
			throws IOException {
		if (Boolean.TRUE.equals(useFastExceptions.get())) {
			return NOT_FOUND_CONNECTION;
		}
		return new JarURLConnection(null, jarFile, jarEntryName);
	}

	/**
	 * A JarEntryName parsed from a URL String.
	 */
	static class JarEntryName {

		private final String name;

		private String contentType;

		JarEntryName(String spec) {
			this.name = decode(spec);
		}

		private String decode(String source) {
			if (source.isEmpty() || (source.indexOf('%') < 0)) {
				return source;
			}
			ByteArrayOutputStream bos = new ByteArrayOutputStream(source.length());
			write(source, bos);
			// AsciiBytes is what is used to store the JarEntries so make it symmetric
			return AsciiBytes.toString(bos.toByteArray());
		}

		private void write(String source, ByteArrayOutputStream outputStream) {
			int length = source.length();
			for (int i = 0; i < length; i++) {
				int c = source.charAt(i);
				if (c > 127) {
					try {
						String encoded = URLEncoder.encode(String.valueOf((char) c),
								"UTF-8");
						write(encoded, outputStream);
					}
					catch (UnsupportedEncodingException ex) {
						throw new IllegalStateException(ex);
					}
				}
				else {
					if (c == '%') {
						if ((i + 2) >= length) {
							throw new IllegalArgumentException(
									"Invalid encoded sequence \"" + source.substring(i)
											+ "\"");
						}
						c = decodeEscapeSequence(source, i);
						i += 2;
					}
					outputStream.write(c);
				}
			}
		}

		private char decodeEscapeSequence(String source, int i) {
			int hi = Character.digit(source.charAt(i + 1), 16);
			int lo = Character.digit(source.charAt(i + 2), 16);
			if (hi == -1 || lo == -1) {
				throw new IllegalArgumentException(
						"Invalid encoded sequence \"" + source.substring(i) + "\"");
			}
			return ((char) ((hi << 4) + lo));
		}

		@Override
		public String toString() {
			return this.name;
		}

		public boolean isEmpty() {
			return this.name.isEmpty();
		}

		public String getContentType() {
			if (this.contentType == null) {
				this.contentType = deduceContentType();
			}
			return this.contentType;
		}

		private String deduceContentType() {
			// Guess the content type, don't bother with streams as mark is not supported
			String type = (isEmpty() ? "x-java/jar" : null);
			type = (type != null ? type : guessContentTypeFromName(toString()));
			type = (type != null ? type : "content/unknown");
			return type;
		}

		public static JarEntryName get(String spec) {
			return get(spec, 0);
		}

		public static JarEntryName get(String spec, int beginIndex) {
			if (spec.length() <= beginIndex) {
				return EMPTY_JAR_ENTRY_NAME;
			}
			return new JarEntryName(spec.substring(beginIndex));
		}

	}

}
