/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.apache.taverna.databundle;

import static java.nio.file.Files.createDirectories;
import static java.nio.file.Files.delete;
import static java.nio.file.Files.isDirectory;
import static java.nio.file.Files.isRegularFile;
import static java.nio.file.Files.newDirectoryStream;
import static java.nio.file.Files.newInputStream;
import static java.nio.file.Files.newOutputStream;
import static java.nio.file.Files.readAllLines;
import static java.nio.file.Files.write;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.lang.reflect.Array;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.net.URLStreamHandler;
import java.nio.charset.Charset;
import java.nio.file.DirectoryIteratorException;
import java.nio.file.DirectoryStream;
import java.nio.file.DirectoryStream.Filter;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
import java.util.NavigableMap;
import java.util.TreeMap;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import org.apache.taverna.databundle.DataBundles.ResolveOptions;
import org.apache.taverna.robundle.Bundle;
import org.apache.taverna.robundle.Bundles;
import org.apache.taverna.scufl2.api.container.WorkflowBundle;
import org.apache.taverna.scufl2.api.io.ReaderException;
import org.apache.taverna.scufl2.api.io.WorkflowBundleIO;
import org.apache.taverna.scufl2.api.io.WriterException;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * Utility functions for dealing with data bundles.
 * <p>
 * The style of using this class is similar to that of {@link Files}. In fact, a
 * data bundle is implemented as a set of {@link Path}s.
 * 
 */
public class DataBundles extends Bundles {
	private static final class OBJECT_MAPPER {
		// Lazy initialization of singleton
		private static final ObjectMapper instance = new ObjectMapper();
	}

	protected static final class ExtensionIgnoringFilter implements
			Filter<Path> {
		private final String fname;

		private ExtensionIgnoringFilter(Path file) {
			this.fname = filenameWithoutExtension(file);
		}

		@Override
		public boolean accept(Path entry) throws IOException {
			return fname.equals(filenameWithoutExtension(entry));
		}
	}

	private static WorkflowBundleIO wfBundleIO;

	private static Logger logger = Logger.getLogger(DataBundles.class.getCanonicalName());

	private static final String WFBUNDLE_CONTENT_TYPE = "application/vnd.taverna.scufl2.workflow-bundle";
	private static final String WFDESC_TURTLE = "text/vnd.wf4ever.wfdesc+turtle";
	private static final String WORKFLOW = "workflow";
	private static final String DOT_WFDESC_TTL = ".wfdesc.ttl";
	private static final String DOT_WFBUNDLE = ".wfbundle";
	private static final String WORKFLOWRUN_PROV_TTL = "workflowrun.prov.ttl";
	private static final String WORKFLOWRUN_JSON = "workflowrun.json";
	private static final String DOT_ERR = ".err";
	private static final String INPUTS = "inputs";
	private static final String INTERMEDIATES = "intermediates";
	private static final String OUTPUTS = "outputs";
	private static final Charset UTF8 = Charset.forName("UTF-8");

	private static Path anyExtension(Path path) throws IOException {
		return anyExtension(path.getParent(), path.getFileName().toString());
	}

	private static Path anyExtension(Path directory, String fileName)
			throws IOException {
		Path path = directory.resolve(fileName);

		// Prefer the fileName as it is
		if (Files.exists(path))
			return path;
		// Strip any existing extension
		String fileNameNoExt = filenameWithoutExtension(path);
		Path withoutExt = path.resolveSibling(fileNameNoExt);
		if (Files.exists(withoutExt))
			return withoutExt;

		// Check directory for path.*
		for (Path p : newDirectoryStream(directory, fileNameNoExt + ".*"))
			/*
			 * We'll just return the first one
			 * 
			 * TODO: Should we fail if there's more than one?
			 */
			return p;

		/*
		 * Nothing? Then let's give the existing one; perhaps it is to be
		 * created.
		 */
		return path;
	}

	private static void checkExistingAnyExtension(Path path)
			throws IOException, FileAlreadyExistsException {
		Path existing = anyExtension(path);
		if (!path.equals(existing))
			throw new FileAlreadyExistsException(existing.toString());
	}

	public static void createList(Path path) throws IOException {
		checkExistingAnyExtension(path);
		Files.createDirectories(path);
	}

	public static void deleteAllExtensions(final Path file) throws IOException {
		Filter<Path> filter = new ExtensionIgnoringFilter(file);
		try (DirectoryStream<Path> ds = newDirectoryStream(file.getParent(),
				filter)) {
			for (Path p : ds)
				deleteRecursively(p);
		}
	}

	protected static String filenameWithoutExtension(Path entry) {
		String fileName = entry.getFileName().toString();
		int lastDot = fileName.lastIndexOf(".");
		if (lastDot < 0)
			return fileName.replace("/", "");
		return fileName.substring(0, lastDot);
	}

	public static ErrorDocument getError(Path path) throws IOException {
		if (path == null)
			return null;

		Path errorPath = withExtension(path, DOT_ERR);
		List<String> errorList = readAllLines(errorPath, UTF8);
		int split = errorList.indexOf("");
		if (split == -1 || errorList.size() <= split)
			throw new IOException("Invalid error document: " + errorPath);

		ErrorDocument errorDoc = new ErrorDocument();

		for (String cause : errorList.subList(0, split))
			errorDoc.getCausedBy().add(path.resolveSibling(cause));

		errorDoc.setMessage(errorList.get(split + 1));

		StringBuilder errorTrace = new StringBuilder();
		for (String line : errorList.subList(split + 2, errorList.size())) {
			errorTrace.append(line);
			errorTrace.append("\n");
		}
		if (errorTrace.length() > 0)
			// Delete last \n
			errorTrace.deleteCharAt(errorTrace.length() - 1);
		errorDoc.setTrace(errorTrace.toString());
		return errorDoc;
	}

	public static Path getInputs(Bundle dataBundle) throws IOException {
		Path inputs = dataBundle.getRoot().resolve(INPUTS);
		createDirectories(inputs);
		return inputs;
	}

	private static long getEntryNumber(Path entry) throws NumberFormatException {
		String name = filenameWithoutExtension(entry);
		return Long.parseLong(name);
	}

	public static List<Path> getList(Path list) throws IOException {
		if (list == null)
			return null;
		List<Path> paths = new ArrayList<>();
		try (DirectoryStream<Path> ds = newDirectoryStream(list)) {
			for (Path entry : ds)
				try {
					long entryNum = getEntryNumber(entry);
					while (paths.size() <= entryNum)
						// Fill any gaps
						paths.add(null);
					// NOTE: Don't use add() as these could come in any order!
					paths.set((int) entryNum, entry);
				} catch (NumberFormatException ex) {
				}
		} catch (DirectoryIteratorException ex) {
			throw ex.getCause();
		}
		return paths;
	}

	public static Path getListItem(Path list, long position) throws IOException {
		if (position < 0)
			throw new IllegalArgumentException(
					"Position must be 0 or more, not: " + position);
		return anyExtension(list, Long.toString(position));
	}

	public static Path getOutputs(Bundle dataBundle) throws IOException {
		Path inputs = dataBundle.getRoot().resolve(OUTPUTS);
		createDirectories(inputs);
		return inputs;
	}

	public static Path getPort(Path map, String portName) throws IOException {
		Files.createDirectories(map);
		return anyExtension(map, portName);
	}

	public static NavigableMap<String, Path> getPorts(Path path)
			throws IOException {
		NavigableMap<String, Path> ports = new TreeMap<>();
		try (DirectoryStream<Path> ds = newDirectoryStream(path)) {
			for (Path p : ds)
				ports.put(filenameWithoutExtension(p), p);
		}
		return ports;
	}

	public static boolean hasInputs(Bundle dataBundle) {
		Path inputs = dataBundle.getRoot().resolve(INPUTS);
		return isDirectory(inputs);
	}

	public static boolean hasOutputs(Bundle dataBundle) {
		Path outputs = dataBundle.getRoot().resolve(OUTPUTS);
		return isDirectory(outputs);
	}

	public static boolean isError(Path path) {
		return isRegularFile(withExtension(path, DOT_ERR));
	}

	public static boolean isList(Path path) {
		return isDirectory(path);
	}

	public static boolean isMissing(Path item) {
		return Bundles.isMissing(item) && !isError(item);
	}

	public static boolean isValue(Path item) {
		return !isError(item) && Bundles.isValue(item);
	}

	public static Path newListItem(Path list) throws IOException {
		createList(list);
		return list.resolve(Long.toString(getListSize(list)));
	}

	public static Path setError(Path path, ErrorDocument error)
			throws IOException {
		return setError(path, error.getMessage(), error.getTrace(), error
				.getCausedBy().toArray(new Path[error.getCausedBy().size()]));
	}

	public static Path setError(Path errorPath, String message, String trace,
			Path... causedBy) throws IOException {
		errorPath = withExtension(errorPath, DOT_ERR);
		// Silly \n-based format
		List<String> errorDoc = new ArrayList<>();
		for (Path cause : causedBy) {
			Path relCause = errorPath.getParent().relativize(cause);
			errorDoc.add(relCause.toString());
		}
		errorDoc.add(""); // Our magic separator
		errorDoc.add(message);
		errorDoc.add(trace);
		checkExistingAnyExtension(errorPath);
		write(errorPath, errorDoc, UTF8, TRUNCATE_EXISTING, CREATE);
		return errorPath;
	}

	public static Path setReference(Path path, URI reference)
			throws IOException {
		path = withExtension(path, DOT_URL);
		checkExistingAnyExtension(path);
		return Bundles.setReference(path, reference);
	}

	public static void setStringValue(Path path, String string)
			throws IOException {
		checkExistingAnyExtension(path);
		Bundles.setStringValue(path, string);
	}

	protected static Path withExtension(Path path, String extension) {
		String filename = path.getFileName().toString();
		return path.resolveSibling(withExtensionFilename(filename, extension));
	}

	protected static String withExtensionFilename(String filename,
			String extension) {
		if (!extension.isEmpty() && !extension.startsWith("."))
			throw new IllegalArgumentException(
					"Extension must be empty or start with .");
		if (!extension.isEmpty()
				&& filename.toLowerCase().endsWith(extension.toLowerCase()))
			return filename;
		// Everything after the last . - or just the end
		return filename.replaceFirst("(\\.[^.]*)?$", extension);
	}

	public static Path getWorkflowRunProvenance(Bundle dataBundle) {
		return dataBundle.getRoot().resolve(WORKFLOWRUN_PROV_TTL);
	}

	public static Path getWorkflowRunReport(Bundle dataBundle) {
		return dataBundle.getRoot().resolve(WORKFLOWRUN_JSON);
	}

	public static JsonNode getWorkflowRunReportAsJson(Bundle dataBundle)
			throws IOException {
		Path path = getWorkflowRunReport(dataBundle);
		try (InputStream jsonIn = newInputStream(path)) {
			return OBJECT_MAPPER.instance.readTree(jsonIn);
		}
	}

	public static void setWorkflowRunReport(Bundle dataBundle,
			JsonNode workflowRunReport) throws IOException {
		Path path = getWorkflowRunReport(dataBundle);
		try (OutputStream out = newOutputStream(path)) {
			OBJECT_MAPPER.instance.writeValue(out, workflowRunReport);
		}
	}

	public static Path getWorkflow(Bundle dataBundle) throws IOException {
		return anyExtension(dataBundle.getRoot(), WORKFLOW);
	}

	public static Path getWorkflowDescription(Bundle dataBundle)
			throws IOException {
		Path annotations = getAnnotations(dataBundle);
		return annotations.resolve(WORKFLOW + DOT_WFDESC_TTL);
	}

	public static void setWorkflowBundle(Bundle dataBundle,
			WorkflowBundle wfBundle) throws IOException {
		Path bundlePath = withExtension(getWorkflow(dataBundle), DOT_WFBUNDLE);
		checkExistingAnyExtension(bundlePath);

		// TODO: Save as nested folder?
		try (OutputStream outputStream = newOutputStream(bundlePath)) {
			getWfBundleIO().writeBundle(wfBundle, outputStream,
					WFBUNDLE_CONTENT_TYPE);
		} catch (WriterException e) {
			throw new IOException("Can't write workflow bundle to: "
					+ bundlePath, e);
		}

		// wfdesc
		Path wfdescPath = getWorkflowDescription(dataBundle);
		try (OutputStream outputStream = newOutputStream(wfdescPath)) {
			getWfBundleIO().writeBundle(wfBundle, outputStream, WFDESC_TURTLE);
		} catch (IllegalArgumentException | WriterException e) {
			logger.log(Level.WARNING, "Can't write wfdesc to: " + bundlePath, e);
			delete(wfdescPath);
			// throw new IOException("Can't write wfdesc to: " + bundlePath, e);
		}
	}

	public static WorkflowBundle getWorkflowBundle(Bundle dataBundle)
			throws ReaderException, IOException {
		Path wf = getWorkflow(dataBundle);
		// String type = Files.probeContentType(wf);
		return getWfBundleIO().readBundle(newInputStream(wf), null);
	}

	public static Path getIntermediates(Bundle dataBundle) throws IOException {
		Path intermediates = dataBundle.getRoot().resolve(INTERMEDIATES);
		createDirectories(intermediates);
		return intermediates;
	}

	public static Path getIntermediate(Bundle dataBundle, UUID uuid)
			throws IOException {
		String fileName = uuid.toString();
		Path intermediates = getIntermediates(dataBundle);
		// Folder is named after first 2 characters of UUID
		Path folder = intermediates.resolve(fileName.substring(0, 2));
		createDirectories(folder);
		return anyExtension(folder, fileName);
	}

	public static long getListSize(Path list) throws IOException {
		// Should fail if list is not a directory
		try (DirectoryStream<Path> ds = newDirectoryStream(list)) {
			long max = -1L;
			for (Path entry : ds)
				try {
					long entryNum = getEntryNumber(entry);
					if (entryNum > max)
						max = entryNum;
				} catch (NumberFormatException ex) {
				}
			return max + 1;
		} catch (DirectoryIteratorException ex) {
			throw ex.getCause();
		}
	}
	
	public enum ResolveOptions { 
		/**
		 * Leaf values are represented as bundle {@link Path}s, except errors as
		 * {@link ErrorDocument} and references as {@link URL}. Note that specifying this
		 * option does not negate any of the other options like {@link #BYTES}.
		 */
		DEFAULT,
		/**
		 * Leaf values should be represented as a {@link String} (NOTE: This won't work well if the path is a binary)
		 */
		STRING,
		/**
		 * Leaf values should be represented as a <code>byte[]</code>
		 */
		BYTES,
		/**
		 * Leaf values should always be represented as {@link URI}s (except errors)
		 */
		URI,
		/**
		 * Leaf values should be represented as bundle {@link Path}s (even if they are errors)
		 */
		PATH,
		/**
		 * Replace errors with <code>null</code>, or the empty string if {@link #REPLACE_NULL} is also specified.
		 */
		REPLACE_ERRORS,
		/**
		 * Instead of returning <code>null</code>, return the empty
		 * {@link String} "", or empty byte[] if {@link #BYTES} is specified, or
		 * the missing path if {@link #PATH} is specified.
		 */
		REPLACE_NULL
	}
		
	/**
	 * Deeply resolve a {@link Path} to JVM objects.
	 * <p>
	 * This method is intended mainly for presentational uses 
	 * with a particular input/output port from
	 * {@link #getPorts(Path)} or {@link #getPort(Path, String)}.
	 * <p>
	 * Note that as all lists are resolved deeply (including lists of lists)
	 * and when using options {@link ResolveOptions#STRING} or {@link ResolveOptions#BYTES}
	 * the full content of the values are read into memory, this 
	 * method can be time-consuming.
	 * <p>
	 * If the path is <code>null</code> or {@link #isMissing(Path)},
	 * <code>null</code> is returned, unless the option
	 * {@link ResolveOptions#REPLACE_NULL} is specified, which would return the
	 * empty String "".
	 * <p>
	 * If the path {@link #isValue(Path)} and the option
	 * {@link ResolveOptions#STRING} is specified, its
	 * {@link #getStringValue(Path)} is returned (assuming an UTF-8 encoding).
	 * NOTE: Binary formats (e.g. PNG) will NOT be represented correctly read as
	 * UTF-8 String and should instead be read directly with
	 * {@link Files#newInputStream(Path, java.nio.file.OpenOption...)}. Note
	 * that this could consume a large amount of memory as no size checks are
	 * performed.
	 * <p>
	 * If the option {@link ResolveOptions#URI} is specified, all non-missing 
	 * non-error leaf values are resolved as a {@link URI}. If the path is a 
	 * {@link #isReference(Path)} the URI will be the reference from 
	 * {@link #getReference(Path)}, otherwise the URI will  
	 * identify a {@link Path} within the current {@link Bundle}.
	 * <p>
	 * If the path {@link #isValue(Path)} and the option
	 * {@link ResolveOptions#BYTES} is specified, the complete content is returned as
	 * a <code>byte[]</code>. Note that this could consume a large amount of memory
	 * as no size checks are performed.
	 * <p>
	 * If the path {@link #isError(Path)}, the corresponding
	 * {@link ErrorDocument} is returned, except when the option
	 * {@link ResolveOptions#REPLACE_ERRORS} is specified, which means errors are
	 * returned as <code>null</code> (or <code>""</code> if {@link ResolveOptions#REPLACE_NULL} is also specified).
	 * <p>
	 * If the path {@link #isReference(Path)} and the option 
	 * {@link ResolveOptions#URI} is <strong>not</strong> set, 
	 * either a {@link File} or a {@link URL} is returned, 
	 * depending on its protocol. If the reference protocol has no
	 * corresponding {@link URLStreamHandler}, a {@link URI} is returned
	 * instead. 
	 * <p>
	 * If the path {@link #isList(Path)}, a {@link List} is returned
	 * corresponding to resolving the paths from {@link #getList(Path)}. using
	 * this method with the same options.
	 * <p>
	 * If none of the above, the {@link Path} itself is returned. This is 
	 * thus the default for non-reference non-error leaf values if neither 
	 * {@link ResolveOptions#STRING}, {@link ResolveOptions#BYTES} or
	 * {@link ResolveOptions#URI} are specified.
	 * To force returning of {@link Path}s for all non-missing leaf values, specify
	 * {@link ResolveOptions#PATH};
	 * 
	 * @param path
	 *            Data bundle path to resolve
	 * @param options
	 *            Resolve options
	 * @return <code>null</code>, a {@link String}, {@link ErrorDocument},
	 *         {@link URL}, {@link File}, {@link Path} or {@link List}
	 *         (containing any of these) depending on the path type and the options.
	 * @throws IOException
	 *             If the path (or any of the path in a contained list) can't be
	 *             accessed
	 */
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public static Object resolve(Path path, ResolveOptions... options) throws IOException {
		EnumSet<ResolveOptions> opt;
		if (options.length == 0) {
			opt = EnumSet.of(ResolveOptions.DEFAULT); // no-op
		} else {
			opt = EnumSet.of(ResolveOptions.DEFAULT, options);
		}
		
		if (opt.contains(ResolveOptions.BYTES) && opt.contains(ResolveOptions.STRING)) {
			throw new IllegalArgumentException("Incompatible options: BYTES and STRING");
		}
		if (opt.contains(ResolveOptions.BYTES) && opt.contains(ResolveOptions.PATH)) {
			throw new IllegalArgumentException("Incompatible options: BYTES and PATH");
		}
		if (opt.contains(ResolveOptions.BYTES) && opt.contains(ResolveOptions.URI)) {
			throw new IllegalArgumentException("Incompatible options: BYTES and URI");
		}
		if (opt.contains(ResolveOptions.STRING) && opt.contains(ResolveOptions.PATH)) {
			throw new IllegalArgumentException("Incompatible options: STRING and PATH");
		}
		if (opt.contains(ResolveOptions.STRING) && opt.contains(ResolveOptions.URI)) {
			throw new IllegalArgumentException("Incompatible options: STRING and URI");
		}
		if (opt.contains(ResolveOptions.PATH) && opt.contains(ResolveOptions.URI)) {
			throw new IllegalArgumentException("Incompatible options: PATH and URI");
		}

		
		if (path == null || isMissing(path)) {
			if (! opt.contains(ResolveOptions.REPLACE_NULL)) { 
				return null;
			}
			if (opt.contains(ResolveOptions.BYTES)) {
				return new byte[0];
			}
			if (opt.contains(ResolveOptions.PATH)) { 
				return path;
			}
			if (opt.contains(ResolveOptions.URI)) {
				return path.toUri();
			}
			// STRING and DEFAULT
			return "";			
			
 
		}
		
		if (isList(path)) {
			List<Path> list = getList(path);
			List<Object> objectList = new ArrayList<Object>(list.size());
			for (Path pathElement : list) {
				objectList.add(resolve(pathElement, options));
			}
			return objectList;
		}		
		if (opt.contains(ResolveOptions.PATH)) {
			return path;
		}		
		if (isError(path)) {
			if (opt.contains(ResolveOptions.REPLACE_ERRORS)) {
				return opt.contains(ResolveOptions.REPLACE_NULL) ? "" : null;	
			}
			return getError(path);
		}
		if (opt.contains(ResolveOptions.URI)) {
			if (isReference(path)) {
				return getReference(path);
			} else {
				return path.toUri();
			}
		}
		if (isReference(path)) {
			URI reference = getReference(path);
			String scheme = reference.getScheme();
			if ("file".equals(scheme)) {
				return new File(reference);
			} else {
				try { 
					return reference.toURL();
				} catch (IllegalArgumentException|MalformedURLException e) {
					return reference;
				}
			}
		}
		if (isValue(path)) {
			if (opt.contains(ResolveOptions.BYTES)) {
				return Files.readAllBytes(path);
			}
			if (opt.contains(ResolveOptions.STRING)) {
				return getStringValue(path);
			}
		}
		// Fall-back - return Path as-is
		return path;
	}

	/**
	 * Deeply resolve path as a {@link Stream} that only contain leaf elements of 
	 * the specified class.
	 * <p>
	 * This method is somewhat equivalent to {@link #resolve(Path, ResolveOptions...)}, but 
	 * the returned stream is not in any particular order, and will contain the leaf
	 * items from all deep lists. Empty lists and error documents are ignored.
	 * <p>
	 * Any {@link IOException}s occurring during resolution are 
	 * wrapped as {@link UncheckedIOException}.
	 * <p>
	 * Supported types include:
	 * <ul>
	 *   <li>{@link String}.class</li>
	 *   <li><code>byte[].class</code></li>
	 *   <li>{@link Path}.class</li>
	 *   <li>{@link URI}.class</li>
	 *   <li>{@link URL}.class</li>  
	 *   <li>{@link File}.class</li>
	 *   <li>{@link ErrorDocument}.class</li>
	 *   <li>{@link Object}.class</li>
	 * </ul>
	 * 
	 * @param path Data bundle path to resolve
	 * @param type Type of objects to return, e.g. <code>String.class</code>
	 * @return A {@link Stream} of resolved objects, or an empty stream if no such objects were resolved.
	 * @throws UncheckedIOException If the path could not be accessed. 
	 */
	public static <T> Stream<T> resolveAsStream(Path path, Class<T> type) throws UncheckedIOException {
		ResolveOptions options;
		if (type == String.class) {
			options = ResolveOptions.STRING;
		} else if (type == byte[].class) {
			options = ResolveOptions.BYTES;
		} else if (type == Path.class) {
			options = ResolveOptions.PATH;
		} else if (type == URI.class) {
			options = ResolveOptions.URI;
		} else {
			// Dummy-option, we'll filter on the returned type anyway
			options = ResolveOptions.DEFAULT;
		}
		if (isList(path)) {
			// return Stream of unordered list of resolved list items,	
			// recursing to find the leaf nodes			
			try {
				return Files.walk(path)
						// avoid re-recursion
						.filter(p -> !Files.isDirectory(p)) 
						.flatMap(p -> resolveItemAsStream(p, type, options));
			} catch (IOException e) {
				throw new UncheckedIOException(e);
			}
		} else {
			return resolveItemAsStream(path, type, options);
		}
	}
	private static <T> Stream<T> resolveItemAsStream(Path path, Class<T> type, ResolveOptions options) throws UncheckedIOException {
		try {
			Object value = resolve(path, options);
			if (type.isInstance(value)) {
				return Stream.of(type.cast(value));
			}
			return Stream.empty();
		} catch (IOException e) {
			throw new UncheckedIOException(e);
		}
	}
	
	public static WorkflowBundleIO getWfBundleIO() {
		if (wfBundleIO == null)
			wfBundleIO = new WorkflowBundleIO();
		return wfBundleIO;
	}

	public static void setWfBundleIO(WorkflowBundleIO wfBundleIO) {
		if (wfBundleIO == null)
			throw new NullPointerException();
		DataBundles.wfBundleIO = wfBundleIO;
	}
}