// ***************************************************************************************************************************
// * 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.juneau.cp;

import static org.apache.juneau.collections.JsonMap.*;
import static org.apache.juneau.common.internal.IOUtils.*;
import static org.apache.juneau.common.internal.StringUtils.*;
import static org.apache.juneau.internal.CollectionUtils.*;
import static org.apache.juneau.internal.FileUtils.*;
import static org.apache.juneau.internal.ObjectUtils.*;

import java.io.*;
import java.util.*;
import java.util.ResourceBundle.*;
import java.util.concurrent.*;
import java.util.regex.*;

import org.apache.juneau.common.internal.*;
import org.apache.juneau.internal.*;

/**
 * Basic implementation of a {@link FileFinder}.
 *
 * <p>
 * Specialized behavior can be implemented by overridding the {@link #find(String, Locale)} method.
 *
 * <h5 class='section'>Example:</h5>
 * <p class='bjava'>
 * 	<jk>public class</jk> MyFileFinder <jk>extends</jk> BasicFileFinder {
 * 		<ja>@Override</ja>
 * 		<jk>protected</jk> Optional&lt;InputStream&gt; find(String <jv>name</jv>, Locale <jv>locale</jv>) <jk>throws</jk> IOException {
 * 			<jc>// Do special handling or just call super.find().</jc>
 * 			<jk>return super</jk>.find(<jv>name</jv>, <jv>locale</jv>);
 * 		}
 * 	}
 * </p>
 *
 * <h5 class='section'>See Also:</h5><ul>
 * </ul>
 */
public class BasicFileFinder implements FileFinder {


	private static final ResourceBundle.Control RB_CONTROL = ResourceBundle.Control.getControl(Control.FORMAT_DEFAULT);

	private final Map<String,LocalFile> files = new ConcurrentHashMap<>();
	private final Map<Locale,Map<String,LocalFile>> localizedFiles = new ConcurrentHashMap<>();

	private final LocalDir[] roots;
	private final long cachingLimit;
	private final Pattern[] include, exclude;
	private final String[] includePatterns, excludePatterns;
	private final int hashCode;

	/**
	 * Builder-based constructor.
	 *
	 * @param builder The builder object.
	 */
	public BasicFileFinder(FileFinder.Builder builder) {
		this.roots = builder.roots.toArray(new LocalDir[builder.roots.size()]);
		this.cachingLimit = builder.cachingLimit;
		this.include = builder.include;
		this.exclude = builder.exclude;
		this.includePatterns = alist(include).stream().map(Pattern::pattern).toArray(String[]::new);
		this.excludePatterns = alist(exclude).stream().map(Pattern::pattern).toArray(String[]::new);
		this.hashCode = HashCode.of(getClass(), roots, cachingLimit, includePatterns, excludePatterns);
	}

	/**
	 * Default constructor.
	 *
	 * <p>
	 * Can be used when providing a subclass that overrides the {@link #find(String, Locale)} method.
	 */
	protected BasicFileFinder() {
		this.roots = new LocalDir[0];
		this.cachingLimit = -1;
		this.include = new Pattern[0];
		this.exclude = new Pattern[0];
		this.includePatterns = new String[0];
		this.excludePatterns = new String[0];
		this.hashCode = HashCode.of(getClass(), roots, cachingLimit, includePatterns, excludePatterns);
	}

	//-----------------------------------------------------------------------------------------------------------------
	// FileFinder methods
	//-----------------------------------------------------------------------------------------------------------------

	@Override /* FileFinder */
	public final Optional<InputStream> getStream(String name, Locale locale) throws IOException {
		return find(name, locale);
	}

	@Override /* FileFinder */
	public Optional<String> getString(String name, Locale locale) throws IOException {
		return optional(read(find(name, locale).orElse(null)));
	}

	//-----------------------------------------------------------------------------------------------------------------
	// Implementation methods
	//-----------------------------------------------------------------------------------------------------------------

	/**
	 * The main implementation method for finding files.
	 *
	 * <p>
	 * Subclasses can override this method to provide their own handling.
	 *
	 * @param name The resource name.
	 * 	See {@link Class#getResource(String)} for format.
	 * @param locale
	 * 	The locale of the resource to retrieve.
	 * 	<br>If <jk>null</jk>, won't look for localized file names.
	 * @return The resolved resource contents, or <jk>null</jk> if the resource was not found.
	 * @throws IOException Thrown by underlying stream.
	 */
	protected Optional<InputStream> find(String name, Locale locale) throws IOException {
		name = StringUtils.trimSlashesAndSpaces(name);

		if (isInvalidPath(name))
			return empty();

		if (locale != null)
			localizedFiles.putIfAbsent(locale, new ConcurrentHashMap<>());

		Map<String,LocalFile> fileCache = locale == null ? files : localizedFiles.get(locale);

		LocalFile lf = fileCache.get(name);

		if (lf == null) {
			List<String> candidateFileNames = getCandidateFileNames(name, locale);
			paths: for (LocalDir root : roots) {
				for (String cfn : candidateFileNames) {
					lf = root.resolve(cfn);
					if (lf != null)
						break paths;
				}
			}

			if (lf != null && isIgnoredFile(lf.getName()))
				lf = null;

			if (lf != null) {
				fileCache.put(name, lf);

				if (cachingLimit >= 0) {
					long size = lf.size();
					if (size > 0 && size <= cachingLimit)
						lf.cache();
				}
			}
		}

		return optional(lf == null ? null : lf.read());
	}

	/**
	 * Returns the candidate file names for the specified file name in the specified locale.
	 *
	 * <p>
	 * For example, if looking for the <js>"MyResource.txt"</js> file in the Japanese locale, the iterator will return
	 * names in the following order:
	 * <ol>
	 * 	<li><js>"MyResource_ja_JP.txt"</js>
	 * 	<li><js>"MyResource_ja.txt"</js>
	 * 	<li><js>"MyResource.txt"</js>
	 * </ol>
	 *
	 * <p>
	 * If the locale is <jk>null</jk>, then it will only return <js>"MyResource.txt"</js>.
	 *
	 * @param fileName The name of the file to get candidate file names on.
	 * @param locale
	 * 	The locale.
	 * 	<br>If <jk>null</jk>, won't look for localized file names.
	 * @return An iterator of file names to look at.
	 */
	protected List<String> getCandidateFileNames(final String fileName, final Locale locale) {

		if (locale == null)
			return Collections.singletonList(fileName);

		List<String> list = new ArrayList<>();
		String baseName = getBaseName(fileName);
		String ext = getExtension(fileName);

		getCandidateLocales(locale).forEach(x -> {
			String ls = x.toString();
			if (ls.isEmpty())
				list.add(fileName);
			else {
				list.add(baseName + "_" + ls + (ext.isEmpty() ? "" : ('.' + ext)));
				list.add(ls.replace('_', '/') + '/' + fileName);
			}
		});

		return list;
	}

	/**
	 * Returns the candidate locales for the specified locale.
	 *
	 * <p>
	 * For example, if <c>locale</c> is <js>"ja_JP"</js>, then this method will return:
	 * <ol>
	 * 	<li><js>"ja_JP"</js>
	 * 	<li><js>"ja"</js>
	 * 	<li><js>""</js>
	 * </ol>
	 *
	 * @param locale The locale to get the list of candidate locales for.
	 * @return The list of candidate locales.
	 */
	protected List<Locale> getCandidateLocales(Locale locale) {
		return RB_CONTROL.getCandidateLocales("", locale);
	}

	/**
	 * Checks for path malformations such as use of <js>".."</js> which can be used to open up security holes.
	 *
	 * <p>
	 * Default implementation returns <jk>true</jk> if the path is any of the following:
	 * <ul>
	 * 	<li>Is blank or <jk>null</jk>.
	 * 	<li>Contains <js>".."</js> (to prevent traversing out of working directory).
	 * 	<li>Contains <js>"%"</js> (to prevent URI trickery).
	 * </ul>
	 *
	 * @param path The path to check.
	 * @return <jk>true</jk> if the path is invalid.
	 */
	protected boolean isInvalidPath(String path) {
		return isEmpty(path) || path.contains("..") || path.contains("%");
	}

	/**
	 * Returns <jk>true</jk> if the file should be ignored based on file name.
	 *
	 * @param name The name to check.
	 * @return <jk>true</jk> if the file should be ignored.
	 */
	protected boolean isIgnoredFile(String name) {
		for (Pattern p : exclude)
			if (p.matcher(name).matches())
				return true;
		for (Pattern p : include)
			if (p.matcher(name).matches())
				return false;
		return true;
	}

	@Override
	public int hashCode() {
		return hashCode;
	}

	@Override /* Object */
	public boolean equals(Object o) {
		return o instanceof BasicFileFinder && eq(this, (BasicFileFinder)o, (x,y)->eq(x.hashCode, y.hashCode) && eq(x.getClass(), y.getClass()) && eq(x.roots, y.roots) && eq(x.cachingLimit, y.cachingLimit) && eq(x.includePatterns, y.includePatterns) && eq(x.excludePatterns, y.excludePatterns));
	}

	@Override /* Object */
	public String toString() {
		return filteredMap()
			.append("class", getClass().getSimpleName())
			.append("roots", roots)
			.append("cachingLimit", cachingLimit)
			.append("include", includePatterns)
			.append("exclude", excludePatterns)
			.append("hashCode", hashCode)
			.asReadableString();
	}
}
