/*
 * Copyright 2012-2014 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.archive;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.Manifest;

import org.springframework.boot.loader.util.AsciiBytes;

/**
 * {@link Archive} implementation backed by an exploded archive directory.
 *
 * @author Phillip Webb
 */
public class ExplodedArchive extends Archive {

	private static final Set<String> SKIPPED_NAMES = new HashSet<String>(Arrays.asList(
			".", ".."));

	private static final AsciiBytes MANIFEST_ENTRY_NAME = new AsciiBytes(
			"META-INF/MANIFEST.MF");

	private final File root;

	private Map<AsciiBytes, Entry> entries = new LinkedHashMap<AsciiBytes, Entry>();

	private Manifest manifest;

	private boolean filtered = false;

	/**
	 * Create a new {@link ExplodedArchive} instance.
	 * @param root the root folder
	 */
	public ExplodedArchive(File root) {
		this(root, true);
	}

	/**
	 * Create a new {@link ExplodedArchive} instance.
	 * @param root the root folder
	 * @param recursive if recursive searching should be used to locate the manifest.
	 * Defaults to {@code true}, folders with a large tree might want to set this to {code
	 * false}.
	 */
	public ExplodedArchive(File root, boolean recursive) {
		if (!root.exists() || !root.isDirectory()) {
			throw new IllegalArgumentException("Invalid source folder " + root);
		}
		this.root = root;
		buildEntries(root, recursive);
		this.entries = Collections.unmodifiableMap(this.entries);
	}

	private ExplodedArchive(File root, Map<AsciiBytes, Entry> entries) {
		this.root = root;
		// The entries are pre-filtered
		this.filtered = true;
		this.entries = Collections.unmodifiableMap(entries);
	}

	private void buildEntries(File file, boolean recursive) {
		if (!file.equals(this.root)) {
			String name = file.toURI().getPath()
					.substring(this.root.toURI().getPath().length());
			FileEntry entry = new FileEntry(new AsciiBytes(name), file);
			this.entries.put(entry.getName(), entry);
		}
		if (file.isDirectory()) {
			File[] files = file.listFiles();
			if (files == null) {
				return;
			}
			for (File child : files) {
				if (!SKIPPED_NAMES.contains(child.getName())) {
					if (file.equals(this.root) || recursive
							|| file.getName().equals("META-INF")) {
						buildEntries(child, recursive);
					}
				}
			}
		}
	}

	@Override
	public URL getUrl() throws MalformedURLException {
		FilteredURLStreamHandler handler = this.filtered ? new FilteredURLStreamHandler()
				: null;
		return new URL("file", "", -1, this.root.toURI().getPath(), handler);
	}

	@Override
	public Manifest getManifest() throws IOException {
		if (this.manifest == null && this.entries.containsKey(MANIFEST_ENTRY_NAME)) {
			FileEntry entry = (FileEntry) this.entries.get(MANIFEST_ENTRY_NAME);
			FileInputStream inputStream = new FileInputStream(entry.getFile());
			try {
				this.manifest = new Manifest(inputStream);
			}
			finally {
				inputStream.close();
			}
		}
		return this.manifest;
	}

	@Override
	public List<Archive> getNestedArchives(EntryFilter filter) throws IOException {
		List<Archive> nestedArchives = new ArrayList<Archive>();
		for (Entry entry : getEntries()) {
			if (filter.matches(entry)) {
				nestedArchives.add(getNestedArchive(entry));
			}
		}
		return Collections.unmodifiableList(nestedArchives);
	}

	@Override
	public Collection<Entry> getEntries() {
		return Collections.unmodifiableCollection(this.entries.values());
	}

	protected Archive getNestedArchive(Entry entry) throws IOException {
		File file = ((FileEntry) entry).getFile();
		return (file.isDirectory() ? new ExplodedArchive(file) : new JarFileArchive(file));
	}

	@Override
	public Archive getFilteredArchive(EntryRenameFilter filter) throws IOException {
		Map<AsciiBytes, Entry> filteredEntries = new LinkedHashMap<AsciiBytes, Archive.Entry>();
		for (Map.Entry<AsciiBytes, Entry> entry : this.entries.entrySet()) {
			AsciiBytes filteredName = filter.apply(entry.getKey(), entry.getValue());
			if (filteredName != null) {
				filteredEntries.put(filteredName, new FileEntry(filteredName,
						((FileEntry) entry.getValue()).getFile()));
			}
		}
		return new ExplodedArchive(this.root, filteredEntries);
	}

	private class FileEntry implements Entry {

		private final AsciiBytes name;

		private final File file;

		FileEntry(AsciiBytes name, File file) {
			this.name = name;
			this.file = file;
		}

		public File getFile() {
			return this.file;
		}

		@Override
		public boolean isDirectory() {
			return this.file.isDirectory();
		}

		@Override
		public AsciiBytes getName() {
			return this.name;
		}
	}

	/**
	 * {@link URLStreamHandler} that respects filtered entries.
	 */
	private class FilteredURLStreamHandler extends URLStreamHandler {

		@Override
		protected URLConnection openConnection(URL url) throws IOException {
			String name = url.getPath().substring(
					ExplodedArchive.this.root.toURI().getPath().length());
			if (ExplodedArchive.this.entries.containsKey(new AsciiBytes(name))) {
				return new URL(url.toString()).openConnection();
			}
			return new FileNotFoundURLConnection(url, name);
		}
	}

	/**
	 * {@link URLConnection} used to represent a filtered file.
	 */
	private static class FileNotFoundURLConnection extends URLConnection {

		private final String name;

		FileNotFoundURLConnection(URL url, String name) {
			super(url);
			this.name = name;
		}

		@Override
		public void connect() throws IOException {
			throw new FileNotFoundException(this.name);
		}

	}

}
