HintFileRegistry.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer.
 *
 * 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:
 *     Carsten Hammer - initial API and implementation
 *******************************************************************************/
package org.sandbox.jdt.triggerpattern.internal;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.eclipse.core.resources.IContainer;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.IResourceVisitor;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IConfigurationElement;
import org.eclipse.core.runtime.IExtensionRegistry;
import org.eclipse.core.runtime.ILog;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.Status;
import org.osgi.framework.Bundle;
import org.sandbox.jdt.triggerpattern.api.HintFile;
import org.sandbox.jdt.triggerpattern.api.TransformationRule;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser.HintParseException;
import org.sandbox.jdt.triggerpattern.llm.CommitEvaluation;

/**
 * Registry for loading and managing {@code .sandbox-hint} files.
 *
 * <p>The registry provides access to bundled pattern libraries and
 * user-defined hint files. Bundled libraries are loaded from the
 * classpath, while user-defined files can be loaded from the filesystem.</p>
 *
 * <p>Core storage and loading functionality is delegated to
 * {@link HintFileStore} (in {@code sandbox_common_core}), which has no
 * Eclipse/OSGi dependencies. This class adds Eclipse-specific functionality
 * such as extension-point loading, workspace project scanning, and
 * Eclipse logging.</p>
 *
 * <h2>Bundled Pattern Libraries</h2>
 * <p>Generic libraries bundled with the framework:</p>
 * <ul>
 *   <li>{@code collections.sandbox-hint} — Collection API improvements</li>
 *   <li>{@code modernize-java9.sandbox-hint} — Java 9+ API modernization</li>
 *   <li>{@code modernize-java11.sandbox-hint} — Java 11+ API modernization</li>
 *   <li>{@code performance.sandbox-hint} — Performance optimizations</li>
 * </ul>
 *
 * <p>Domain-specific libraries are provided by their dedicated plugins:</p>
 * <ul>
 *   <li>{@code encoding.sandbox-hint} — in {@code sandbox_encoding_quickfix}</li>
 *   <li>{@code junit5.sandbox-hint} — in {@code sandbox_junit_cleanup}</li>
 * </ul>
 *
 * <p>This class is thread-safe. All mutable state is protected by
 * synchronization or concurrent data structures.</p>
 *
 * @since 1.3.2
 */
public final class HintFileRegistry {

	private static final String HINTS_EXTENSION_POINT_ID = "org.sandbox.jdt.triggerpattern.hints"; //$NON-NLS-1$

	private static final HintFileRegistry INSTANCE = new HintFileRegistry();

	/** Delegate for Eclipse-independent storage and loading. */
	private final HintFileStore store = new HintFileStore();

	/** Tracks which projects have been scanned for workspace hint files. */
	private final java.util.Set<String> loadedProjects = ConcurrentHashMap.newKeySet();

	/**
	 * File extension for sandbox hint files (including the dot).
	 */
	private static final String HINT_FILE_EXTENSION = ".sandbox-hint"; //$NON-NLS-1$

	/**
	 * File extension for NetBeans-compatible hint files (including the dot).
	 */
	private static final String NETBEANS_HINT_FILE_EXTENSION = ".hint"; //$NON-NLS-1$

	private HintFileRegistry() {
		// Singleton
	}

	/**
	 * Returns the singleton instance.
	 *
	 * @return the hint file registry
	 */
	public static HintFileRegistry getInstance() {
		return INSTANCE;
	}

	/**
	 * Returns the underlying {@link HintFileStore} for direct access to
	 * Eclipse-independent functionality.
	 *
	 * @return the hint file store
	 * @since 1.2.6
	 */
	public HintFileStore getStore() {
		return store;
	}

	/**
	 * Loads and registers a hint file from a string.
	 *
	 * @param id the unique ID for this hint file
	 * @param content the hint file content
	 * @throws HintParseException if parsing fails
	 */
	public void loadFromString(String id, String content) throws HintParseException {
		store.loadFromString(id, content);
	}

	/**
	 * Loads and registers a hint file from a reader.
	 *
	 * @param id the unique ID for this hint file
	 * @param reader the reader to read from
	 * @throws HintParseException if parsing fails
	 * @throws IOException if an I/O error occurs
	 */
	public void loadFromReader(String id, Reader reader) throws HintParseException, IOException {
		store.loadFromReader(id, reader);
	}

	/**
	 * Loads and registers a hint file from a classpath resource.
	 *
	 * @param id the unique ID for this hint file
	 * @param resourcePath the classpath resource path
	 * @param classLoader the class loader to use for loading
	 * @return {@code true} if the resource was found and loaded
	 * @throws HintParseException if parsing fails
	 * @throws IOException if an I/O error occurs
	 */
	public boolean loadFromClasspath(String id, String resourcePath, ClassLoader classLoader)
			throws HintParseException, IOException {
		return store.loadFromClasspath(id, resourcePath, classLoader);
	}

	/**
	 * Returns a registered hint file by ID.
	 *
	 * @param id the hint file ID
	 * @return the hint file, or {@code null} if not found
	 */
	public HintFile getHintFile(String id) {
		return store.getHintFile(id);
	}

	/**
	 * Returns all registered hint files.
	 *
	 * @return unmodifiable map of ID to hint file
	 */
	public Map<String, HintFile> getAllHintFiles() {
		return store.getAllHintFiles();
	}

	/**
	 * Returns the IDs of all registered hint files.
	 *
	 * @return unmodifiable list of hint file IDs
	 */
	public List<String> getRegisteredIds() {
		return store.getRegisteredIds();
	}

	/**
	 * Removes a registered hint file.
	 *
	 * @param id the hint file ID to remove
	 * @return the removed hint file, or {@code null} if not found
	 */
	public HintFile unregister(String id) {
		return store.unregister(id);
	}

	/**
	 * Removes all registered hint files.
	 */
	public void clear() {
		store.clear();
		loadedProjects.clear();
	}

	/**
	 * Resolves include directives for a hint file by collecting all rules
	 * from referenced hint files.
	 *
	 * <p>When a hint file has {@code <!include: other-id>} directives, this method
	 * looks up the referenced hint files by ID and returns a combined list of all
	 * rules (the file's own rules plus all included rules).</p>
	 *
	 * <p>Circular includes are detected and silently broken to prevent infinite loops.</p>
	 *
	 * @param hintFile the hint file whose includes should be resolved
	 * @return list of all rules including those from included files
	 * @since 1.3.4
	 */
	public List<TransformationRule> resolveIncludes(HintFile hintFile) {
		return store.resolveIncludes(hintFile);
	}

	/**
	 * Returns the names of bundled pattern libraries.
	 *
	 * @return array of bundled library resource names
	 */
	public static String[] getBundledLibraryNames() {
		return HintFileStore.getBundledLibraryNames();
	}

	/**
	 * Attempts to load all bundled pattern libraries from the classpath.
	 * Libraries that are not found are silently skipped.
	 *
	 * @param classLoader the class loader to use for loading resources
	 * @return list of successfully loaded library IDs
	 */
	public List<String> loadBundledLibraries(ClassLoader classLoader) {
		return store.loadBundledLibraries(classLoader);
	}

	/**
	 * Loads {@code .sandbox-hint} files registered via the
	 * {@code org.sandbox.jdt.triggerpattern.hints} extension point.
	 *
	 * <p>This method queries the Eclipse extension registry for {@code hintFile}
	 * elements contributed by other plugins. Each element specifies an {@code id}
	 * and a {@code resource} path pointing to a {@code .sandbox-hint} file on the
	 * contributing bundle's classpath.</p>
	 *
	 * @return list of successfully loaded hint file IDs
	 * @since 1.3.6
	 */
	public List<String> loadFromExtensions() {
		List<String> loaded = new ArrayList<>();
		IExtensionRegistry registry = Platform.getExtensionRegistry();
		if (registry == null) {
			return loaded;
		}
		IConfigurationElement[] elements = registry.getConfigurationElementsFor(HINTS_EXTENSION_POINT_ID);
		for (IConfigurationElement element : elements) {
			if (!"hintFile".equals(element.getName())) { //$NON-NLS-1$
				continue;
			}
			String id = element.getAttribute("id"); //$NON-NLS-1$
			String resource = element.getAttribute("resource"); //$NON-NLS-1$
			if (id == null || resource == null) {
				continue;
			}
			// Check enabled attribute (default: true)
			String enabledAttr = element.getAttribute("enabled"); //$NON-NLS-1$
			if (enabledAttr != null && "false".equalsIgnoreCase(enabledAttr)) { //$NON-NLS-1$
				continue;
			}
			// Skip already loaded hint files
			if (store.getHintFile(id) != null) {
				continue;
			}
			Bundle bundle = Platform.getBundle(element.getContributor().getName());
			if (bundle == null) {
				continue;
			}
			try {
				URL resourceUrl = bundle.getResource(resource);
				if (resourceUrl == null) {
					ILog log = Platform.getLog(HintFileRegistry.class);
					log.log(Status.warning(
							"Hint file resource not found: " + resource //$NON-NLS-1$
									+ " in bundle " + bundle.getSymbolicName())); //$NON-NLS-1$
					continue;
				}
				try (InputStream is = resourceUrl.openStream();
						Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
					store.loadFromReader(id, reader);
					loaded.add(id);
				}
			} catch (HintParseException | IOException e) {
				ILog log = Platform.getLog(HintFileRegistry.class);
				log.log(Status.warning(
						"Failed to load hint file from extension: " + id, e)); //$NON-NLS-1$
			}
		}
		return loaded;
	}

	/**
	 * Discovers and loads {@code .sandbox-hint} and {@code .hint} files from a workspace project.
	 *
	 * <p>Scans the project root for files with the {@code .sandbox-hint} or
	 * {@code .hint} extension and registers them. Each project is scanned at
	 * most once; subsequent calls with the same project are no-ops.</p>
	 *
	 * <p>This enables users to define custom transformation rules per project
	 * by placing hint files in the project directory. NetBeans {@code .hint}
	 * files are also supported for interoperability.</p>
	 *
	 * @param project the Eclipse project to scan
	 * @return list of successfully loaded hint file IDs from this project
	 * @since 1.3.6
	 */
	public List<String> loadProjectHintFiles(IProject project) {
		if (project == null || !project.isAccessible()) {
			return Collections.emptyList();
		}
		String projectKey = project.getName();
		if (!loadedProjects.add(projectKey)) {
			return Collections.emptyList(); // Already scanned
		}
		List<String> loaded = new ArrayList<>();
		try {
			project.accept(new IResourceVisitor() {
				@Override
				public boolean visit(IResource resource) throws CoreException {
					if (resource instanceof IFile file
							&& isHintFile(file.getName())) {
						String id = "project:" + projectKey + ":" //$NON-NLS-1$ //$NON-NLS-2$
								+ file.getProjectRelativePath().toString();
						try (InputStream is = file.getContents();
								Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
							store.loadFromReader(id, reader);
							loaded.add(id);
						} catch (HintParseException | IOException e) {
							ILog log = Platform.getLog(HintFileRegistry.class);
							log.log(Status.warning(
									"Failed to load hint file: " + file.getFullPath(), e)); //$NON-NLS-1$
						}
					}
					// Skip output folders and hidden directories
					if (resource instanceof IContainer container) {
						String name = container.getName();
						return !name.startsWith(".") //$NON-NLS-1$
								&& !"bin".equals(name) //$NON-NLS-1$
								&& !"target".equals(name); //$NON-NLS-1$
					}
					return true;
				}
			});
		} catch (CoreException e) {
			ILog log = Platform.getLog(HintFileRegistry.class);
			log.log(Status.warning(
					"Failed to scan project for hint files: " + projectKey, e)); //$NON-NLS-1$
		}
		return loaded;
	}

	/**
	 * Forces a re-scan of the given project on the next call to
	 * {@link #loadProjectHintFiles(IProject)}.
	 *
	 * <p>This is useful when a project's {@code .sandbox-hint} files have changed
	 * and need to be reloaded.</p>
	 *
	 * @param project the project to invalidate
	 * @since 1.3.6
	 */
	public void invalidateProject(IProject project) {
		if (project != null) {
			loadedProjects.remove(project.getName());
		}
	}

	/**
	 * Checks if a file name has a recognized hint file extension.
	 *
	 * @param fileName the file name to check
	 * @return {@code true} if the file has a {@code .sandbox-hint} or {@code .hint} extension
	 * @since 1.3.8
	 */
	private static boolean isHintFile(String fileName) {
		return fileName.endsWith(HINT_FILE_EXTENSION) || fileName.endsWith(NETBEANS_HINT_FILE_EXTENSION);
	}

	/**
	 * Registers a set of inferred rules as a new hint file in the registry.
	 *
	 * <p>The rules are wrapped in a {@link HintFile} with the tag
	 * {@code "inferred"} and the given source commit ID for traceability.
	 * The hint file is immediately available for CleanUp and QuickAssist.</p>
	 *
	 * @param hintFile     the hint file containing inferred rules
	 * @param sourceCommit the commit ID from which the rules were derived
	 * @since 1.2.6
	 */
	public void registerInferredRules(HintFile hintFile, String sourceCommit) {
		store.registerInferredRules(hintFile, sourceCommit);
	}

	/**
	 * Returns all hint files that were inferred (have the "inferred" tag prefix).
	 *
	 * @return list of inferred hint files
	 * @since 1.2.6
	 */
	public List<HintFile> getInferredHintFiles() {
		return store.getInferredHintFiles();
	}

	/**
	 * Promotes an inferred hint file to a manual (user-authored) one by
	 * removing the "inferred:" prefix from its ID and re-registering it.
	 *
	 * @param hintFileId the original inferred hint file ID
	 * @since 1.2.6
	 */
	public void promoteToManual(String hintFileId) {
		store.promoteToManual(hintFileId);
	}

	/**
	 * Registers inferred rules from a list of {@link CommitEvaluation} results.
	 *
	 * <p>Each evaluation with a valid DSL rule is converted into a
	 * {@link HintFile} and registered with the tag {@code "ai-inferred"}.
	 * Evaluations without valid rules are silently skipped.</p>
	 *
	 * @param evaluations the list of commit evaluations from AI inference
	 * @param source      a label identifying the source of these evaluations
	 * @return list of IDs of successfully registered hint files
	 * @since 1.3.2
	 */
	public List<String> registerInferredRules(List<CommitEvaluation> evaluations, String source) {
		return store.registerInferredRules(evaluations, source);
	}

	/**
	 * Saves all AI-inferred hint files to the {@code .hints/} directory
	 * of the given Eclipse project.
	 *
	 * <p>Each inferred hint file is serialized and written to
	 * {@code <project>/.hints/ai-inferred-<id>.sandbox-hint}.
	 * The project is refreshed after writing so Eclipse picks up the changes.</p>
	 *
	 * @param project the Eclipse project whose {@code .hints/} directory to use
	 * @return list of paths that were written
	 * @since 1.3.2
	 */
	public List<Path> saveInferredHintFiles(IProject project) {
		if (project == null || !project.isAccessible()) {
			return Collections.emptyList();
		}
		org.eclipse.core.runtime.IPath location = project.getLocation();
		if (location == null) {
			ILog log = Platform.getLog(HintFileRegistry.class);
			log.log(Status.warning(
					"Cannot save inferred hint files: project has no local location: " //$NON-NLS-1$
							+ project.getName()));
			return Collections.emptyList();
		}
		try {
			Path projectPath = location.toFile().toPath();
			List<Path> written = store.saveInferredHintFiles(projectPath);
			if (!written.isEmpty()) {
				project.refreshLocal(IProject.DEPTH_INFINITE, null);
			}
			return written;
		} catch (IOException | CoreException e) {
			ILog log = Platform.getLog(HintFileRegistry.class);
			log.log(Status.warning(
					"Failed to save inferred hint files for project: " //$NON-NLS-1$
							+ project.getName(), e));
			return Collections.emptyList();
		}
	}

	/**
	 * Loads persisted AI-inferred hint files from the {@code .hints/}
	 * directory of the given Eclipse project.
	 *
	 * <p>This should be called on workspace startup to restore previously
	 * saved inferred rules.</p>
	 *
	 * @param project the Eclipse project to load from
	 * @return list of IDs of loaded hint files
	 * @since 1.3.2
	 */
	public List<String> loadInferredHintFiles(IProject project) {
		if (project == null || !project.isAccessible()) {
			return Collections.emptyList();
		}
		org.eclipse.core.runtime.IPath location = project.getLocation();
		if (location == null) {
			return Collections.emptyList();
		}
		Path projectPath = location.toFile().toPath();
		return store.loadInferredHintFiles(projectPath);
	}
}