/*
 * Copyright 2015-2026 the original author or authors.
 *
 * All rights reserved. This program and the accompanying materials are
 * made available under the terms of the Eclipse Public License v2.0 which
 * accompanies this distribution and is available at
 *
 * https://www.eclipse.org/legal/epl-v20.html
 */

package org.junit.platform.launcher.listeners;

import static java.util.Objects.requireNonNull;
import static org.apiguardian.api.API.Status.STABLE;

import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

import org.apiguardian.api.API;
import org.jspecify.annotations.Nullable;
import org.junit.platform.commons.logging.Logger;
import org.junit.platform.commons.logging.LoggerFactory;
import org.junit.platform.engine.ConfigurationParameters;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.TestIdentifier;
import org.junit.platform.launcher.TestPlan;

/**
 * {@code UniqueIdTrackingListener} is a {@link TestExecutionListener} that tracks
 * the {@linkplain TestIdentifier#getUniqueId() unique IDs} of all
 * {@linkplain TestIdentifier#isTest() tests} that were
 * {@linkplain #executionFinished executed} during the execution of the
 * {@link TestPlan} and generates a file containing the unique IDs once execution
 * of the {@code TestPlan} has {@linkplain #testPlanExecutionFinished(TestPlan)
 * finished}.
 *
 * <p>Tests are tracked regardless of their {@link TestExecutionResult} or whether
 * they were skipped, and the unique IDs are written to an output file, one ID
 * per line, encoding using UTF-8.
 *
 * <p>The output file can be used to execute the same set of tests again without
 * having to query the user configuration for the test plan and without having to
 * perform test discovery again. This can be useful for test environments such as
 * within a native image &mdash; for example, a GraalVM native image &mdash; in
 * order to rerun the exact same tests from a standard JVM test run within a
 * native image.
 *
 * <h2>Configuration and Defaults</h2>
 *
 * <p>The {@code OUTPUT_DIR} is the directory in which this listener generates
 * the output file. The exact path of the generated file is
 * {@code OUTPUT_DIR/OUTPUT_FILE_PREFIX-<random number>.txt}, where
 * {@code <random number>} is a pseudo-random number. The inclusion of a random
 * number in the file name ensures that multiple concurrently executing test
 * plans do not overwrite each other's results.
 *
 * <p>The value of the {@code OUTPUT_FILE_PREFIX} defaults to
 * {@link #DEFAULT_OUTPUT_FILE_PREFIX}, but a custom prefix can be set via the
 * {@link #OUTPUT_FILE_PREFIX_PROPERTY_NAME} configuration property.
 *
 * <p>The {@code OUTPUT_DIR} can be set to a custom directory via the
 * {@link #OUTPUT_DIR_PROPERTY_NAME} configuration property. Otherwise the
 * following algorithm is used to select a default output directory.
 *
 * <ul>
 * <li>If the current working directory of the Java process contains a file named
 * {@code pom.xml}, the output directory will be {@code ./target}, following the
 * conventions of Maven.</li>
 * <li>If the current working directory of the Java process contains a file with
 * the extension {@code .gradle} or {@code .gradle.kts}, the output directory
 * will be {@code ./build}, following the conventions of Gradle.</li>
 * <li>Otherwise, the current working directory of the Java process will be used
 * as the output directory.</li>
 * </ul>
 *
 * <p>For example, in a project using Gradle as the build tool, the file generated
 * by this listener would be {@code ./build/junit-platform-unique-ids-<random number>.txt}
 * by default.
 *
 * <p>Configuration properties can be set via JVM system properties, via a
 * {@code junit-platform.properties} file in the root of the classpath, or as
 * JUnit Platform {@linkplain ConfigurationParameters configuration parameters}.
 *
 * @since 1.8
 */
@API(status = STABLE, since = "1.11")
public class UniqueIdTrackingListener implements TestExecutionListener {

	/**
	 * Property name used to enable the {@code UniqueIdTrackingListener}: {@value}
	 *
	 * <p>The {@code UniqueIdTrackingListener} is registered automatically via
	 * Java's {@link java.util.ServiceLoader} mechanism but disabled by default.
	 *
	 * <p>Set the value of this property to {@code true} to enable this listener.
	 */
	public static final String LISTENER_ENABLED_PROPERTY_NAME = "junit.platform.listeners.uid.tracking.enabled";

	/**
	 * Property name used to set the path to the output directory for the file
	 * generated by the {@code UniqueIdTrackingListener}: {@value}
	 *
	 * <p>For details on the default output directory, see the
	 * {@linkplain UniqueIdTrackingListener class-level Javadoc}.
	 */
	public static final String OUTPUT_DIR_PROPERTY_NAME = "junit.platform.listeners.uid.tracking.output.dir";

	/**
	 * Property name used to set the prefix for the name of the file generated
	 * by the {@code UniqueIdTrackingListener}: {@value}
	 *
	 * <p>Defaults to {@link #DEFAULT_OUTPUT_FILE_PREFIX}.
	 */
	public static final String OUTPUT_FILE_PREFIX_PROPERTY_NAME = "junit.platform.listeners.uid.tracking.output.file.prefix";

	/**
	 * The default prefix for the name of the file generated by the
	 * {@code UniqueIdTrackingListener}: {@value}
	 *
	 * @see #OUTPUT_FILE_PREFIX_PROPERTY_NAME
	 */
	public static final String DEFAULT_OUTPUT_FILE_PREFIX = "junit-platform-unique-ids";

	static final String WORKING_DIR_PROPERTY_NAME = "junit.platform.listeners.uid.tracking.working.dir";

	private final Logger logger = LoggerFactory.getLogger(UniqueIdTrackingListener.class);

	private final List<String> uniqueIds = new ArrayList<>();

	private boolean enabled;

	private @Nullable TestPlan testPlan;

	public UniqueIdTrackingListener() {
		// to avoid missing-explicit-ctor warning
	}

	@Override
	public void testPlanExecutionStarted(TestPlan testPlan) {
		this.enabled = testPlan.getConfigurationParameters().getBoolean(LISTENER_ENABLED_PROPERTY_NAME).orElse(false);
		this.testPlan = testPlan;
	}

	@Override
	public void executionSkipped(TestIdentifier testIdentifier, String reason) {
		if (this.enabled) {
			// When a container is skipped, there are no events for its children.
			// Therefore, in order to track them, we need to traverse the subtree.
			trackTestUidRecursively(testIdentifier);
		}
	}

	@Override
	public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) {
		if (this.enabled) {
			trackTestUid(testIdentifier);
		}
	}

	private void trackTestUidRecursively(TestIdentifier testIdentifier) {
		boolean tracked = trackTestUid(testIdentifier);
		if (!tracked) {
			requireNonNull(this.testPlan).getChildren(testIdentifier).forEach(this::trackTestUidRecursively);
		}
	}

	private boolean trackTestUid(TestIdentifier testIdentifier) {
		if (testIdentifier.isTest()) {
			this.uniqueIds.add(testIdentifier.getUniqueId());
			return true;
		}
		return false;
	}

	@Override
	public void testPlanExecutionFinished(TestPlan testPlan) {
		if (this.enabled) {
			Path outputFile;
			try {
				outputFile = createOutputFile(testPlan.getConfigurationParameters());
			}
			catch (Exception ex) {
				logger.error(ex, () -> "Failed to create output file");
				// Abort since we cannot generate the file.
				return;
			}

			logger.debug(() -> "Writing unique IDs to output file " + outputFile.toAbsolutePath());
			try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(outputFile, StandardCharsets.UTF_8))) {
				this.uniqueIds.forEach(writer::println);
				writer.flush();
			}
			catch (IOException ex) {
				logger.error(ex, () -> "Failed to write unique IDs to output file " + outputFile.toAbsolutePath());
			}
		}
		this.testPlan = null;
	}

	private Path createOutputFile(ConfigurationParameters configurationParameters) {
		String prefix = configurationParameters.get(OUTPUT_FILE_PREFIX_PROPERTY_NAME) //
				.orElse(DEFAULT_OUTPUT_FILE_PREFIX);
		Supplier<Path> workingDirSupplier = () -> configurationParameters.get(WORKING_DIR_PROPERTY_NAME) //
				.map(Path::of) //
				.orElseGet(() -> Path.of("."));
		return OutputDir.create(configurationParameters.get(OUTPUT_DIR_PROPERTY_NAME), workingDirSupplier) //
				.createFile(prefix, "txt");
	}

}
