import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

class NativeRelocator
{
	private final Path rootDirectory;
	private final Path cacheRoot;
	
	/**
	 * Initializes the NativeRelocator by preparing the environment if necessary.
	 * Executes the appropriate preparation script based on the OS.
	 */
	NativeRelocator(Path rootDirectory)
	{
		this.rootDirectory = rootDirectory;
		this.cacheRoot = this.rootDirectory.resolve("cache");
	}
	
	private void prepare() throws Exception
	{
		if (this.rootDirectory.resolve(".venv").toFile().exists())
		{
			return;
		}
		
		ProcessBuilder processBuilder = new ProcessBuilder();
		processBuilder.directory(this.rootDirectory.toFile());
		
		String os = System.getProperty("os.name").toLowerCase();
		if (os.contains("win"))
		{
			processBuilder.command("powershell", "-ExecutionPolicy", "Bypass", "./prepare.ps1");
		}
		else if (os.contains("nix") 
				|| os.contains("nux") 
				|| os.contains("mac")
				|| os.contains("freebsd"))
		{
			processBuilder.command("./prepare.sh");
		}
		else
		{
			throw new IllegalStateException("Unsupported operating system: " + os);
		}
		
		Process process = processBuilder.start();
		CompletableFuture<Void> outputFuture = readOutputStreams(process);
		
		int exitCode = process.waitFor();
		outputFuture.get();
		
		if (exitCode != 0)
		{
			throw new Exception("Prepare failed: " + exitCode);
		}
	}
	
	/**
	 * Reads and prints the output and error streams of a process asynchronously.
	 *
	 * @param process The process whose streams should be read.
	 * @return A CompletableFuture that completes once all output has been processed.
	 */
	private static CompletableFuture<Void> readOutputStreams(Process process)
	{
		return CompletableFuture.runAsync(() -> {
			try
			{
				while (process.isAlive() || process.getInputStream().available() > 0 || process.getErrorStream().available() > 0)
				{
					if (process.getInputStream().available() > 0)
					{
						byte[] data = new byte[process.getInputStream().available()];
						//noinspection ResultOfMethodCallIgnored
						process.getInputStream().read(data);
						System.out.write(data);
					}
					if (process.getErrorStream().available() > 0)
					{
						byte[] data = new byte[process.getErrorStream().available()];
						//noinspection ResultOfMethodCallIgnored
						process.getErrorStream().read(data);
						System.err.write(data);
					}
					
					//noinspection BusyWait
					Thread.sleep(100);
				}
			}
			catch (Throwable ignored)
			{
			}
		});
	}
	
	/**
	 * Replaces occurrences of a target string in a byte array, ensuring null termination.
	 *
	 * @param byteArray The byte array where replacements should occur.
	 * @param target The string to replace.
	 * @param replacement The replacement string (must not be longer than the target).
	 * @throws IllegalArgumentException if the replacement is longer than the target.
	 */
	private void replaceInNullTerminatedStrings(byte[] byteArray, String target, String replacement)
	{
		if (target.length() < replacement.length())
		{
			throw new IllegalArgumentException("Replacement must be the same length or shorter than the target.");
		}
		
		byte[] targetBytes = target.getBytes(StandardCharsets.US_ASCII);
		byte[] replacementBytes = replacement.getBytes(StandardCharsets.US_ASCII);
		
		byte nullByte = 0;
		
		for (int endPos = 0; endPos < byteArray.length - targetBytes.length - 1; endPos++)
		{
			int startPos = endPos;
			int targetPos = 0;
			while (targetPos < targetBytes.length && byteArray[endPos] == targetBytes[targetPos])
			{
				targetPos++;
				endPos++;
			}
			
			if (targetPos == targetBytes.length)
			{
				System.arraycopy(replacementBytes, 0, byteArray, startPos, replacementBytes.length);
				
				startPos = startPos + replacementBytes.length;
				while (byteArray[endPos] != nullByte)
				{
					byteArray[startPos] = byteArray[endPos];
					endPos++;
					startPos++;
				}
				byteArray[startPos] = nullByte;
			}
		}
	}
	
	/**
	 * Runs an external script to fix a modified binary and returns the processed content.
	 *
	 * @param outputFilePath Path to store the processed binary.
	 * @param content The original binary content.
	 * @return The modified binary content.
	 * @throws Exception if the process execution fails.
	 */
	public byte[] fixModifiedBinary(Path outputFilePath, byte[] content) throws Exception
	{
		ProcessBuilder processBuilder = new ProcessBuilder();
		processBuilder.directory(this.rootDirectory.toFile());
		
		processBuilder.command(
				this.rootDirectory.resolve(".venv/Scripts").toFile().exists()
						? this.rootDirectory.resolve(".venv/Scripts/python.exe").toString()
						: this.rootDirectory.resolve(".venv/bin/python").toString(),
				"./fix_modified_binary.py",
				outputFilePath.toString()
		);
		
		Process process = processBuilder.start();
		CompletableFuture<Void> outputFuture = readOutputStreams(process);
		
		process.getOutputStream().write(content);
		process.getOutputStream().close();
		
		int exitCode = process.waitFor();
		outputFuture.get();
		
		if (exitCode != 0)
		{
			throw new Exception("Process failed: " + exitCode);
		}
		
		return Files.readAllBytes(outputFilePath);
	}
	
	/**
	 * Processes a binary file, applying string replacements and fixing modifications.
	 *
	 * @param outputPath The output file path relative to the cache directory.
	 * @param content The binary content to process.
	 * @param replacements A map of string replacements to apply.
	 * @return The modified binary content.
	 * @throws Exception if processing fails.
	 */
	public byte[] processBinary(String outputPath, byte[] content, Map<String, String> replacements) throws Exception
	{
		Path outputFilePath = this.cacheRoot.resolve(outputPath);
		//noinspection ResultOfMethodCallIgnored
		outputFilePath.getParent().toFile().mkdirs();
		
		if (outputFilePath.toFile().exists())
		{
			return Files.readAllBytes(outputFilePath);
		}
		
		System.out.println("Relocating to " + outputPath + "...");
		this.prepare();
		
		for (Map.Entry<String, String> replacement : replacements.entrySet())
		{
			this.replaceInNullTerminatedStrings(content, replacement.getKey(), replacement.getValue());
		}
		
		return this.fixModifiedBinary(outputFilePath, content);
	}
	
}
