/*******************************************************************************
 * Copyright (c) 2020, 2021 Andrey Loskutov and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Andrey Loskutov <loskutov@gmx.de> - initial API and implementation
 *******************************************************************************/
package org.eclipse.jdt.internal.compiler.util;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.nio.channels.ClosedByInterruptException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileSystem;
import java.nio.file.FileSystemAlreadyExistsException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.ProviderNotFoundException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Abstraction to the ct.sym file access (see https://openjdk.java.net/jeps/247). The ct.sym file is required to
 * implement JEP 247 feature (compile with "--release" option against class stubs for older releases) and is currently
 * (Java 15) a jar file with undocumented internal structure, currently existing in at least two different format
 * versions (pre Java 12 and Java 12 and later).
 * <p>
 * The only documentation known seem to be the current implementation of
 * com.sun.tools.javac.platform.JDKPlatformProvider and probably some JDK build tools that construct ct.sym file. Root
 * directories inside the file are somehow related to the Java release number, encoded as single digit or letter (single
 * digits for releases 7 to 9, capital letters for 10 and higher).
 * <p>
 * If a release directory contains "system-modules" file, it is a flag that this release files are not inside ct.sym
 * file because it is the current release, and jrt file system should be used instead.
 * <p>
 * All other release directories contain encoded signature (*.sig) files with class stubs for classes in the release.
 * <p>
 * Some directories contain files that are shared between different releases, exact logic how they are distributed is
 * not known.
 * <p>
 * Known format versions of ct.sym:
 * <p>
 * Pre JDK 12:
 *
 * <pre>
 * ct.sym -> 9 -> java/ -> lang/
 * ct.sym -> 9-modules -> java.base -> module-info.sig
 * </pre>
 *
 * From JDK 12 onward:
 *
 * <pre>
 * ct.sym -> 9 -> java.base -> java/ -> lang/
 * ct.sym -> 9 -> java.base -> module-info.sig
 * </pre>
 *
 * Notably,
 * <ol>
 * <li>in JDK 12 modules classes and ordinary classes are located in the same location
 * <li>in JDK 12, ordinary classes are found inside their respective modules
 * </ol>
 * <p>
 *
 * Searching a file for a given release in ct.sym means finding & traversing all possible release related directories
 * and searching for matching path.
 */
public class CtSym {

	/**
	 * 'B' is code for Java 11, see {@link #getReleaseCode(String)}.
	 */
	private static final char JAVA_11 = 'B';

	public static final boolean DISABLE_CACHE = Boolean.getBoolean("org.eclipse.jdt.disable_CTSYM_cache"); //$NON-NLS-1$

	static boolean VERBOSE = false;

	/**
	 * Map from path (release) inside ct.sym file to all class signatures loaded
	 */
	private final Map<Path, Optional<byte[]>> fileCache = new ConcurrentHashMap<>(10007);

	private final Path jdkHome;

	private final Path ctSymFile;

	private FileSystem fs;

	Path root;

	private boolean isJRE12Plus;

	/**
	 * Paths of all root directories, per release (versions encoded). e.g. in JDK 11, Java 10 mapping looks like A -> [A,
	 * A-modules, A789, A9] but to have more fun, in JDK 14, same mapping looks like A -> [A, AB, ABC, ABCD]
	 */
	private final Map<String, List<Path>> releaseRootPaths = new ConcurrentHashMap<>();

	/**
	 * All paths that exist in all release root directories, per release (versions encoded). The first key is release
	 * code. The second key is the "full qualified binary name" of the class (without module name and
	 * with .sig suffix). The value is the full path of the corresponding signature file in the ct.sym file.
	 */
	private final Map<String, Map<String, Path>> allReleasesPaths = new ConcurrentHashMap<>();

	CtSym(Path jdkHome) throws IOException {
		this.jdkHome = jdkHome;
		this.ctSymFile = jdkHome.resolve("lib/ct.sym"); //$NON-NLS-1$
		init();
	}

	private void init() throws IOException {
		boolean exists = Files.exists(this.ctSymFile);
		if (!exists) {
			throw new FileNotFoundException("File " + this.ctSymFile + " does not exist"); //$NON-NLS-1$//$NON-NLS-2$
		}
		FileSystem fst = null;
		URI uri = URI.create("jar:file:" + this.ctSymFile.toUri().getRawPath()); //$NON-NLS-1$
		try {
			fst = FileSystems.getFileSystem(uri);
		} catch (Exception fne) {
			// Ignore and move on
		}
		if (fst == null) {
			try {
				fst = FileSystems.newFileSystem(uri, new HashMap<>(), ClassLoader.getSystemClassLoader());
			} catch (FileSystemAlreadyExistsException e) {
				fst = FileSystems.getFileSystem(uri);
			} catch (ProviderNotFoundException e) {
				throw new IOException("Failed to create ct.sym file system for " + this.ctSymFile, e); //$NON-NLS-1$
			}
		}
		this.fs = fst;
		if (fst == null) {
			throw new IOException("Failed to create ct.sym file system for " + this.ctSymFile); //$NON-NLS-1$
		} else {
			this.root = fst.getPath("/"); //$NON-NLS-1$
			this.isJRE12Plus = isCurrentRelease12plus();
		}
	}

	/**
	 * @return never null
	 */
	public FileSystem getFs() {
		return this.fs;
	}

	/**
	 *
	 * @return true if this file is from Java 12+ JRE
	 */
	public boolean isJRE12Plus() {
		return this.isJRE12Plus;
	}

	/**
	 * @return never null
	 */
	public Path getRoot() {
		return this.root;
	}

	/**
	 * @param releaseCode
	 *            major JDK version segment as version code (8, 9, A, etc)
	 * @return set with all root paths related to given release in ct.sym file
	 */
	public List<Path> releaseRoots(String releaseCode) {
		List<Path> list = this.releaseRootPaths.computeIfAbsent(releaseCode, x -> {
			List<Path> rootDirs = new ArrayList<>();
			try (DirectoryStream<Path> stream = Files.newDirectoryStream(this.root)) {
				for (final Path subdir : stream) {
					String rel = subdir.getFileName().toString();
					if (rel.contains("-")) { //$NON-NLS-1$
						// Ignore META-INF etc. We are only interested in A-Z 0-9
						continue;
					}
					// com.sun.tools.javac.platform.JDKPlatformProvider.PlatformDescriptionImpl.getFileManager()
					// https://github.com/openjdk/jdk/blob/master/src/jdk.compiler/share/classes/com/sun/tools/javac/platform/JDKPlatformProvider.java
					if (rel.contains(releaseCode)) {
						rootDirs.add(subdir);
					} else {
						continue;
					}
				}
			} catch (IOException e) {
				String error = "Failed to init CtSym for release code " + releaseCode + " and path " + this.root; //$NON-NLS-1$ //$NON-NLS-2$
				if (JRTUtil.PROPAGATE_IO_ERRORS) {
					throw new IllegalStateException(error, e);
				} else {
					System.err.println(error);
					e.printStackTrace();
					return Collections.emptyList();
				}
			}
			return Collections.unmodifiableList(rootDirs);
		});
		return list;
	}

	/**
	 * Retrieves the full path in ct.sym file fro given signature file in given release
	 * <p>
	 * 12+: something like
	 * <p>
	 * java/io/Reader.sig -> /879/java.base/java/io/Reader.sig
	 * <p>
	 * before 12:
	 * <p>
	 * java/io/Reader.sig -> /8769/java/io/Reader.sig
	 *
	 * @param releaseCode release number encoded (7,8,9,A,B...)
	 * @param qualifiedSignatureFileName signature file name (without module)
	 * @return corresponding path in ct.sym file system or null if not found
	 */
	public Path getFullPath(String releaseCode, String qualifiedSignatureFileName, String moduleName) {
		String sep = this.fs.getSeparator();
		if (DISABLE_CACHE) {
			List<Path> releaseRoots = releaseRoots(releaseCode);
			for (Path rroot : releaseRoots) {
				// Calculate file path
				Path p = null;
				if (isJRE12Plus()) {
					if (moduleName == null) {
						moduleName = getModuleInJre12plus(releaseCode, qualifiedSignatureFileName);
					}
					p = rroot.resolve(moduleName + sep + qualifiedSignatureFileName);
				} else {
					p = rroot.resolve(qualifiedSignatureFileName);
				}

				// If file is known, read it from ct.sym
				if (Files.exists(p)) {
					if (VERBOSE) {
						System.out.println("found: " + qualifiedSignatureFileName + " in " + p + " for module " + moduleName  + "\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
					}
					return p;
				}
			}
			if (VERBOSE) {
				System.out.println("not found: " + qualifiedSignatureFileName + " for module " + moduleName); //$NON-NLS-1$ //$NON-NLS-2$
			}
			return null;
		}
		Map<String, Path> releasePaths = getCachedReleasePaths(releaseCode);
		Path path;
		if(moduleName != null) {
			// Without this, org.eclipse.jdt.core.tests.model.ModuleBuilderTests.testConvertToModule() fails on 12+ JRE
			path = releasePaths.get(moduleName + sep + qualifiedSignatureFileName);

			// Special handling of broken module schema in java 11 for compilation with --release 9 and --release 10
			if(path == null && !this.isJRE12Plus() && ("A".equals(releaseCode) || "9".equals(releaseCode))){ //$NON-NLS-1$ //$NON-NLS-2$
				path = releasePaths.get(qualifiedSignatureFileName);
			}
		} else {
			path = releasePaths.get(qualifiedSignatureFileName);
		}
		if (VERBOSE) {
			if (path != null) {
				System.out.println("found: " + qualifiedSignatureFileName + " in " + path + " for module " + moduleName +"\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
			} else {
				System.out.println("not found: " + qualifiedSignatureFileName + " for module " + moduleName); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}
		return path;
	}

	public String getModuleInJre12plus(String releaseCode, String qualifiedSignatureFileName) {
		if (DISABLE_CACHE) {
			return findModuleForFileInJre12plus(releaseCode, qualifiedSignatureFileName);
		}
		Map<String, Path> releasePaths = getCachedReleasePaths(releaseCode);
		Path path = releasePaths.get(qualifiedSignatureFileName);
		if (path != null && path.getNameCount() > 2) {
			// First segment is release, second: module
			return path.getName(1).toString();
		}
		return null;
	}

	private String findModuleForFileInJre12plus(String releaseCode, String qualifiedSignatureFileName) {
		for (Path rroot : releaseRoots(releaseCode)) {
			try (DirectoryStream<Path> stream = Files.newDirectoryStream(rroot)) {
				for (final Path subdir : stream) {
					Path p = subdir.resolve(qualifiedSignatureFileName);
					if (Files.exists(p)) {
						if (subdir.getNameCount() == 2) {
							return subdir.getName(1).toString();
						}
					}
				}
			} catch (IOException e) {
				String error = "Failed to read directory " + rroot + " contents in " + this.root; //$NON-NLS-1$ //$NON-NLS-2$
				if (JRTUtil.PROPAGATE_IO_ERRORS) {
					throw new IllegalStateException(error, e);
				} else {
					System.err.println(error);
					e.printStackTrace();
				}
			}
		}
		return null;
	}

	/**
	 * Populates {@link #allReleasesPaths} with the paths of all files within each matching release directory in ct.sym.
	 * This cache is an optimization to avoid excessive calls into the zip filesystem in
	 * {@code ClasspathJrtWithReleaseOption#findClass(String, String, String, String, boolean, Predicate)}.
	 * <p>
	 * 12+: something like
	 * <p>
	 * java.base/javax/net/ssl/SSLSocketFactory.sig -> /89ABC/java.base/javax/net/ssl/SSLSocketFactory.sig
	 * <p> or
	 * javax/net/ssl/SSLSocketFactory.sig -> /89ABC/java.base/javax/net/ssl/SSLSocketFactory.sig
	 * <p>
	 * before 12: javax/net/ssl/SSLSocketFactory.sig -> /89ABC/java.base/javax/net/ssl/SSLSocketFactory.sig
	 */
	private Map<String, Path> getCachedReleasePaths(String releaseCode) {
		Map<String, Path> result = this.allReleasesPaths.computeIfAbsent(releaseCode, x -> {
			List<Path> roots = releaseRoots(releaseCode);
			Map<String, Path> allReleaseFiles = new HashMap<>(4999);
			for (Path start : roots) {
				try {
					Files.walk(start).filter(Files::isRegularFile).forEach(p -> {
						if (isJRE12Plus()) {
							// Don't use module name as part of the key
							String binaryNameWithoutModule = p.subpath(2, p.getNameCount()).toString();
							allReleaseFiles.put(binaryNameWithoutModule, p);
							// Cache extra key with module added, see getFullPath().
							String binaryNameWithModule = p.subpath(1, p.getNameCount()).toString();
							allReleaseFiles.put(binaryNameWithModule, p);
						} else {
							String binaryNameWithoutModule = p.subpath(1, p.getNameCount()).toString();
							allReleaseFiles.put(binaryNameWithoutModule, p);
						}
					});
				} catch (IOException e) {
					String error = "Failed to read directory " + start + " contents in " + this.root; //$NON-NLS-1$ //$NON-NLS-2$
					if (JRTUtil.PROPAGATE_IO_ERRORS) {
						throw new IllegalStateException(error, e);
					} else {
						// Not much do to if we can't list the dir; anything in there will be treated
						// as if it were missing.
						System.err.println(error);
						e.printStackTrace();
					}
				}
			}
			return Collections.unmodifiableMap(allReleaseFiles);
		});
		return result;
	}

	public byte[] getFileBytes(Path path) throws IOException {
		if (DISABLE_CACHE) {
			return JRTUtil.safeReadBytes(path);
		} else {
			Optional<byte[]> bytes = this.fileCache.computeIfAbsent(path, key -> {
				try {
					return Optional.ofNullable(JRTUtil.safeReadBytes(key));
				} catch (ClosedByInterruptException e) {
					// Don't cache
					return null;
				} catch (IOException e) {
					// remember there is nothing to return
					return Optional.empty();
				}
			});
			if (VERBOSE) {
				System.out.println("got bytes: " + path); //$NON-NLS-1$
			}
			return bytes == null ? null : bytes.orElse(null);
		}
	}

	private boolean isCurrentRelease12plus() throws IOException {
		// ignore everything that is not one character (Java release code is one character plus separator)
		try (DirectoryStream<Path> stream = Files.newDirectoryStream(this.root, p -> p.toString().length() == 2)) {
			for (final Path subdir : stream) {
				String rel = JRTUtil.sanitizedFileName(subdir);
				if (rel.length() != 1) {
					continue;
				}
				try {
					char releaseCode = rel.charAt(0);
					// If a release directory contains "system-modules" file, it is a flag
					// that this is the *current* release
					if (releaseCode > JAVA_11 && Files.exists(this.fs.getPath(rel, "system-modules"))) { //$NON-NLS-1$
						return true;
					}
				} catch (NumberFormatException e) {
					// META-INF, A-modules etc
					continue;
				}
			}
		}
		return false;
	}

	@Override
	public int hashCode() {
		return this.jdkHome.hashCode();
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj) {
			return true;
		}
		if (!(obj instanceof CtSym)) {
			return false;
		}
		CtSym other = (CtSym) obj;
		return this.jdkHome.equals(other.jdkHome);
	}

	@Override
	public String toString() {
		StringBuilder sb = new StringBuilder();
		sb.append("CtSym ["); //$NON-NLS-1$
		sb.append("file="); //$NON-NLS-1$
		sb.append(this.ctSymFile);
		sb.append("]"); //$NON-NLS-1$
		return sb.toString();
	}

	/**
	 * Tries to translate numeric Java version to the corresponding release "code".
	 * <ul>
	 * <li>7, 8 and 9 are just returned "as is"
	 * <li>versions up from 10 are returned as upper letters starting with "A", so 10 is "A", 11 is "B" and so on.
	 * </ul>
	 *
	 * @param release
	 *            release version as number (8, 9, 10, ...)
	 * @return the "code" used by ct.sym for given Java version
	 */
	public static String getReleaseCode(String release) {
		int numericVersion = Integer.parseInt(release);
		if(numericVersion < 10) {
			return String.valueOf(numericVersion);
		}
		return String.valueOf((char) ('A' + (numericVersion - 10)));
	}

}