Editor/EngineBankSlicerWindow.cs
using System;
using System.IO;
using System.Collections.Generic;
using Editor;

namespace RedSnail.EngineBankSlicer.Editor;

/// <summary>
/// Turns one recorded acceleration run into a ready-to-use bank of RPM-indexed loops.
/// </summary>
/// <remarks>
/// Point it at a single clean pull from idle to redline and it writes, for every layer it finds:
///
/// <list type="bullet">
/// <item><c>&lt;rpm&gt;.wav</c> — the loop itself, cut to whole firing cycles and crossfaded at the seam</item>
/// <item><c>&lt;rpm&gt;.wav.meta</c> — with <c>loop: true</c>, which is what makes s&amp;box treat it as sustained</item>
/// <item><c>&lt;rpm&gt;.sound</c> — a SoundEvent pointing at the compiled vsnd</item>
/// </list>
///
/// The filename is the engine speed measured from the audio, so wiring a layer is copying that number into its
/// ReferenceRpm. It also prints the prefab JSON for the whole bank, which is usually faster than filling a list
/// of fifteen entries by hand.
/// </remarks>
public static class EngineBankSlicerWindow
{
	[Menu("Editor", "Vehicles/Slice Engine Bank...", "graphic_eq")]
	public static void Open()
	{
		FileDialog dialog = new(null)
		{
			Title = "Choose an acceleration recording (idle to redline, one pull)",
			DefaultSuffix = ".wav"
		};

		dialog.SetNameFilter("Audio (*.wav)");
		dialog.SetFindExistingFile();
		dialog.SetModeOpen();

		if (!dialog.Execute())
			return;

		string inputPath = dialog.SelectedFile;

		if (string.IsNullOrWhiteSpace(inputPath) || !File.Exists(inputPath))
			return;

		FileDialog output = new(null)
		{
			Title = "Choose the output folder (inside your project's Assets)"
		};

		output.SetFindDirectory();

		if (!output.Execute())
			return;

		string outputPath = output.SelectedFile;

		if (string.IsNullOrWhiteSpace(outputPath))
			return;

		Run(inputPath, outputPath, new SliceOptions());
	}

	/// <summary>
	/// Does the work. Split out from the dialogs so it can be driven from code with explicit options.
	/// </summary>
	public static void Run(string _InputPath, string _OutputPath, SliceOptions _Options)
	{
		WavData wav = EngineBankSlicer.LoadWav(File.ReadAllBytes(_InputPath));

		if (wav is null)
		{
			Log.Warning($"[EngineBankSlicer] '{_InputPath}' is not 16-bit PCM wav. Convert it and try again.");

			return;
		}

		List<SlicedLayer> layers = EngineBankSlicer.Slice(wav, _Options, out string report);

		Log.Info($"[EngineBankSlicer] {Path.GetFileName(_InputPath)}\n{report}");

		if (layers.Count == 0)
		{
			Log.Warning("[EngineBankSlicer] Nothing usable found. Check the cylinder count first — it sets the " +
			            "expected firing rate, and a wrong value moves every detected RPM by the same factor.");

			return;
		}

		Directory.CreateDirectory(_OutputPath);

		// The vsnd path a SoundEvent needs is relative to Assets/ and lowercase, so recover it from wherever the
		// output folder sits rather than asking for it twice.
		string assetRoot = GetAssetRelativePath(_OutputPath);

		if (assetRoot is null)
		{
			Log.Warning($"[EngineBankSlicer] '{_OutputPath}' is not inside an Assets folder, so the SoundEvents " +
			            $"would point nowhere. The wavs were still written; move them and regenerate.");
		}

		foreach (SlicedLayer layer in layers)
		{
			string name = layer.Rpm.ToString();

			File.WriteAllBytes(Path.Combine(_OutputPath, $"{name}.wav"),
				EngineBankSlicer.BuildWav(layer.Samples, layer.SampleRate));

			File.WriteAllText(Path.Combine(_OutputPath, $"{name}.wav.meta"), MetaJson);

			if (assetRoot is not null)
				File.WriteAllText(Path.Combine(_OutputPath, $"{name}.sound"), SoundJson($"{assetRoot}/{name}.vsnd"));
		}

		Log.Info($"[EngineBankSlicer] Wrote {layers.Count} layers to {_OutputPath}\n\n" +
		         $"Paste into a VehicleNoiseSynthesizer's AccelerationLayers:\n{BuildPrefabJson(layers, assetRoot)}");
	}

	/// <summary>
	/// Path relative to Assets/, lowercase with forward slashes — the form asset references take.
	/// </summary>
	private static string GetAssetRelativePath(string _FullPath)
	{
		string normalised = _FullPath.Replace('\\', '/');
		int index = normalised.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);

		if (index < 0)
			return null;

		return normalised[(index + "/Assets/".Length)..].ToLowerInvariant().Trim('/');
	}

	/// <summary>The layer list, ready to paste into a prefab rather than typed in by hand fifteen times.</summary>
	private static string BuildPrefabJson(List<SlicedLayer> _Layers, string _AssetRoot)
	{
		System.Text.StringBuilder builder = new();

		builder.AppendLine("\"AccelerationLayers\": [");

		for (int i = 0; i < _Layers.Count; i++)
		{
			SlicedLayer layer = _Layers[i];
			string comma = i < _Layers.Count - 1 ? "," : "";

			builder.AppendLine("  {");
			builder.AppendLine($"    \"Sound\": \"{_AssetRoot}/{layer.Rpm}.sound\",");
			builder.AppendLine($"    \"ReferenceRpm\": {layer.Rpm},");
			builder.AppendLine("    \"VolumeOffset\": 0,");
			builder.AppendLine("    \"PitchOffset\": 0,");
			builder.AppendLine("    \"LoPitch\": 1,");
			builder.AppendLine("    \"HiPitch\": 1");
			builder.AppendLine($"  }}{comma}");
		}

		builder.AppendLine("]");

		return builder.ToString();
	}

	/// <summary>loop: true is the entire reason this file is written — without it the clips are one-shots.</summary>
	private const string MetaJson =
		"""
		{
		  "loop": true,
		  "start": 0,
		  "end": 0,
		  "forceMono": false,
		  "trimSilence": false,
		  "normalize": false,
		  "gain": 0,
		  "rate": 44100,
		  "compress": false,
		  "bitrate": 256
		}
		""";

	private static string SoundJson(string _VsndPath)
	{
		return $$"""
		{
		  "UI": false,
		  "Volume": "1",
		  "Pitch": "1",
		  "Decibels": 70,
		  "SelectionMode": "Random",
		  "Sounds": [
		    "{{_VsndPath}}"
		  ],
		  "OcclusionEnabled": true,
		  "Occlusion": true,
		  "ReverbEnabled": true,
		  "Reflections": true,
		  "AirAbsorption": true,
		  "Transmission": true,
		  "OcclusionRadius": 64,
		  "DistanceAttenuation": true,
		  "Distance": 5000,
		  "__references": [],
		  "__version": 1
		}
		""";
	}
}