using System.Diagnostics;
using System.Globalization;

internal static class Program
{
	private const int TargetMB = 10, AudioKbps = 96, MinAudioKbps = 32, BitrateOverheadKbps = 32, MaxRetriesPerResolution = 8;
	private const bool PauseAtEnd = true;
	private const string VideoCodec = "libx264", AudioCodec = "aac", Preset = "slower";
	private const double RetryBitrateMultiplier = 0.95;
	private static readonly int[] MaxHeightLadder = [int.MaxValue, 1080, 720, 540, 480, 360, 240];
	private static readonly (int Width, int Height) DefaultCropRatio = (16, 9);

	private static int Main(string[] args)
	{
		Console.OutputEncoding = System.Text.Encoding.UTF8;
		if (args.Length == 0)
		{
			PrintUsage();
			return Exit(1);
		}

		var disableAudio = false;
		var disableCrop = false;
		(int Width, int Height)? requestedCropRatio = null;
		var positionalArgs = new List<string>();
		var stopParsingOptions = false;
		for (var i = 0; i < args.Length; i++)
		{
			var arg = args[i];
			if (!stopParsingOptions)
			{
				if (arg == "--")
				{
					stopParsingOptions = true;
					continue;
				}

				if (arg == "-a")
				{
					disableAudio = true;
					continue;
				}

				if (arg == "-c")
				{
					disableCrop = true;
					continue;
				}

				if (arg == "-r")
				{
					if (i + 1 >= args.Length)
					{
						Console.Error.WriteLine("Option -r requires a crop ratio like 4:3.");
						return Exit(1);
					}

					if (!TryParseAspectRatio(args[++i], out var cropRatio))
					{
						Console.Error.WriteLine("Crop ratio must be in W:H format using positive integers, for example 4:3.");
						return Exit(1);
					}

					requestedCropRatio = cropRatio;
					continue;
				}
			}

			positionalArgs.Add(arg);
		}

		if (disableCrop && requestedCropRatio is not null)
		{
			Console.Error.WriteLine("Options -c and -r cannot be used together.");
			return Exit(1);
		}

		if (positionalArgs.Count == 0)
		{
			PrintUsage();
			return Exit(1);
		}

		var targetMB = TargetMB;
		if (positionalArgs.Count >= 2 && int.TryParse(positionalArgs[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTargetMB))
		{
			if (parsedTargetMB <= 0)
			{
				Console.Error.WriteLine("Target size must be a positive integer number of MB.");
				return Exit(1);
			}

			targetMB = parsedTargetMB;
			positionalArgs.RemoveAt(positionalArgs.Count - 1);
			if (positionalArgs.Count == 0)
			{
				PrintUsage();
				return Exit(1);
			}
		}

		var ffmpegPath = ResolveToolPath("ffmpeg.exe", "ffmpeg");
		var ffprobePath = ResolveToolPath("ffprobe.exe", "ffprobe");
		if (ffmpegPath is null || ffprobePath is null)
		{
			Console.Error.WriteLine("Could not find ffmpeg and ffprobe. Put them in the same folder as the EXE or add them to PATH.");
			return Exit(1);
		}

		var failures = new List<string>();
		foreach (var rawPath in positionalArgs)
		{
			try
			{
				var inputPath = Path.GetFullPath(rawPath.Trim('"'));
				if (!File.Exists(inputPath))
				{
					throw new FileNotFoundException("Input file not found.", inputPath);
				}

				ProcessOne(ffmpegPath, ffprobePath, inputPath, targetMB, disableAudio, disableCrop, requestedCropRatio);
			}
			catch (Exception ex)
			{
				failures.Add($"{rawPath}: {ex.Message}");
				Console.Error.WriteLine($"\nFAILED: {rawPath}\n{ex.Message}");
			}
		}

		if (failures.Count == 0)
		{
			Console.WriteLine("\nAll files completed successfully.");
			return Exit(0);
		}

		Console.Error.WriteLine("\nFinished with errors:");
		foreach (var failure in failures)
		{
			Console.Error.WriteLine($"- {failure}");
		}

		return Exit(1);
	}

	private static void ProcessOne(string ffmpegPath, string ffprobePath, string inputPath, int targetMB, bool disableAudio, bool disableCrop, (int Width, int Height)? requestedCropRatio)
	{
		Console.WriteLine($"\n{new string('=', 72)}\nInput:  {inputPath}");

		var info = ProbeMediaInfo(ffprobePath, inputPath);
		if (info.DurationSeconds <= 0)
		{
			throw new InvalidOperationException("Could not determine media duration.");
		}

		var targetBytes = targetMB * 1024L * 1024L;
		var outputPath = Path.Combine(Path.GetDirectoryName(inputPath) ?? Directory.GetCurrentDirectory(), $"{Path.GetFileNameWithoutExtension(inputPath)}_{targetMB}MB.mp4");
		var passLogBase = Path.Combine(Path.GetTempPath(), $"fitvideo_{Path.GetFileNameWithoutExtension(inputPath)}_{Guid.NewGuid():N}");
		(int Width, int Height)? cropRatio = disableCrop ? null : requestedCropRatio ?? DefaultCropRatio;
		var crop = cropRatio is null
			? null
			: requestedCropRatio is null
				? GetAutoCrop(sourceWidth: info.Width, sourceHeight: info.Height, cropRatio.Value)
				: GetForcedCrop(sourceWidth: info.Width, sourceHeight: info.Height, cropRatio.Value);

		Console.WriteLine($"Output: {outputPath}\nTarget: {targetMB} MB ({targetBytes:N0} bytes)\nSource: {info.Width}x{info.Height}, {info.DurationSeconds:F2}s, {info.FrameRate:F3} fps\nAudio: {(disableAudio ? "disabled" : "enabled")}\nCrop: {DescribeCrop(disableCrop, requestedCropRatio, crop)}");

		try
		{
			foreach (var maxHeight in MaxHeightLadder.Select(h => Math.Min(h, info.Height)).Distinct())
			{
				var resolution = CalculateOutputResolution(info.Width, info.Height, maxHeight, crop);
				var audioKbps = disableAudio ? 0 : GetAudioBitrateKbps(resolution.Height);
				var minVideoKbps = GetMinVideoBitrateKbps(resolution.Height);
				var videoKbps = ComputeStartingVideoBitrateKbps(targetBytes, info.DurationSeconds, audioKbps);

				if (videoKbps < minVideoKbps)
				{
					Console.WriteLine($"\nSkipping {resolution.Width}x{resolution.Height}: budget {videoKbps}k is below floor {minVideoKbps}k.");
					if (resolution.Height > 240 || audioKbps <= MinAudioKbps)
					{
						continue;
					}

					audioKbps = MinAudioKbps;
					videoKbps = ComputeStartingVideoBitrateKbps(targetBytes, info.DurationSeconds, audioKbps);
					if (videoKbps < minVideoKbps)
					{
						Console.WriteLine($"\nTrying {resolution.Width}x{resolution.Height} anyway: budget {videoKbps}k is below floor {minVideoKbps}k.");
					}

				}

				for (var attempt = 1; attempt <= MaxRetriesPerResolution; attempt++)
				{
					Console.WriteLine($"\nTrying {resolution.Width}x{resolution.Height} @ {videoKbps}k video bitrate (attempt {attempt}/{MaxRetriesPerResolution})...");
					if (File.Exists(outputPath))
					{
						File.Delete(outputPath);
					}

					CleanupPassLogs(passLogBase);
					var outputFrameRate = GetOutputFrameRate(info.FrameRate, resolution.Height);
					RunPass(ffmpegPath, inputPath, passLogBase, null, resolution, crop, videoKbps, audioKbps, outputFrameRate, disableAudio);
					RunPass(ffmpegPath, inputPath, passLogBase, outputPath, resolution, crop, videoKbps, audioKbps, outputFrameRate, disableAudio);

					var actualBytes = new FileInfo(outputPath).Length;
					Console.WriteLine($"Result: {actualBytes / 1024d / 1024d:F2} MB");
					if (actualBytes <= targetBytes)
					{
						Console.WriteLine("Success.");
						return;
					}

					var nextVideoKbps = Math.Max(50, (int)Math.Floor(videoKbps * RetryBitrateMultiplier));
					if (nextVideoKbps >= minVideoKbps)
					{
						videoKbps = nextVideoKbps;
						continue;
					}

					if (resolution.Height <= 240 && audioKbps > MinAudioKbps)
					{
						audioKbps = MinAudioKbps;
						videoKbps = minVideoKbps;
						continue;
					}

					Console.WriteLine($"\nSkipping {resolution.Width}x{resolution.Height}: budget {nextVideoKbps}k is below floor {minVideoKbps}k.");
					break;
				}
			}
		}
		finally
		{
			CleanupPassLogs(passLogBase);
		}

		throw new InvalidOperationException("Could not get under the target size with the current ladder and bitrate floors.");
	}

	private static (double DurationSeconds, int Width, int Height, double FrameRate) ProbeMediaInfo(string ffprobePath, string inputPath)
	{
		var durationText = Run(ffprobePath, $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {Quote(inputPath)}", capture: true).Trim();
		if (!double.TryParse(durationText, NumberStyles.Float, CultureInfo.InvariantCulture, out var durationSeconds))
		{
			throw new InvalidOperationException($"ffprobe did not return a valid duration for '{inputPath}'. Output was: {durationText}");
		}

		var parts = Run(ffprobePath, $"-v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x {Quote(inputPath)}", capture: true)
			.Trim()
			.Split('x', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
		if (parts.Length != 2 || !int.TryParse(parts[0], out var width) || !int.TryParse(parts[1], out var height))
		{
			throw new InvalidOperationException($"ffprobe did not return valid dimensions for '{inputPath}'. Output was: {string.Join('x', parts)}");
		}

		var frameRateText = Run(ffprobePath, $"-v error -select_streams v:0 -show_entries stream=avg_frame_rate -of default=noprint_wrappers=1:nokey=1 {Quote(inputPath)}", capture: true).Trim();
		var frameRate = ParseFrameRate(frameRateText);
		if (frameRate <= 0)
		{
			throw new InvalidOperationException($"ffprobe did not return a valid frame rate for '{inputPath}'. Output was: {frameRateText}");
		}

		return (durationSeconds, width, height, frameRate);
	}

	private static void RunPass(string ffmpegPath, string inputPath, string passLogBase, string? outputPath, (int Width, int Height) resolution, (int Width, int Height)? crop, int videoKbps, int audioKbps, double? outputFrameRate, bool disableAudio)
	{
		var args = new List<string>
		{
			"-y",
			"-i", Quote(inputPath),
			"-map", "0:v:0",
			"-c:v", VideoCodec,
			"-b:v", $"{videoKbps}k",
			"-preset", Preset,
			"-vf", Quote(BuildVideoFilter(resolution, crop)),
		};

		if (outputFrameRate is double frameRate)
		{
			args.AddRange(["-r", frameRate.ToString("0.########", CultureInfo.InvariantCulture)]);
		}

		if (outputPath is null || disableAudio)
		{
			args.Add("-an");
		}
		else
		{
			args.AddRange(["-map", "0:a:0?", "-c:a", AudioCodec, "-b:a", $"{audioKbps}k"]);
		}

		args.AddRange(["-pass", outputPath is null ? "1" : "2", "-passlogfile", Quote(passLogBase)]);
		args.AddRange(outputPath is null ? ["-f", "mp4", "NUL"] : [Quote(outputPath)]);
		Run(ffmpegPath, string.Join(' ', args));
	}

	private static int Exit(int code)
	{
		if (PauseAtEnd)
		{
			Console.WriteLine("\nPress any key to close . . . ");
			Console.ReadKey(intercept: true);
		}

		return code;
	}

	private static void PrintUsage()
	{
		Console.WriteLine("Drag one or more video files onto the EXE, or run it from a terminal like:");
		Console.WriteLine("  FitVideo.exe \"C:\\path\\video.mp4\"");
		Console.WriteLine("  FitVideo.exe -a -c \"C:\\path\\video.mp4\"");
		Console.WriteLine("  FitVideo.exe -r 4:3 \"C:\\path\\video.mp4\"");
		Console.WriteLine("  FitVideo.exe \"C:\\path\\video.mp4\" 50");
		Console.WriteLine($"\nDefault target size: {TargetMB} MB");
		Console.WriteLine("Options:");
		Console.WriteLine("  -a    Disable audio.");
		Console.WriteLine("  -c    Do not crop to 16:9.");
		Console.WriteLine("  -r    Crop to a specific aspect ratio, for example 4:3.");
		Console.WriteLine("If the last argument is a positive integer, it overrides the target size in MB for all input files.");
	}

	private static (int Width, int Height) CalculateOutputResolution(int sourceWidth, int sourceHeight, int maxHeight, (int Width, int Height)? crop)
	{
		var inputWidth = crop?.Width ?? sourceWidth;
		var inputHeight = crop?.Height ?? sourceHeight;
		var height = MakeEven(Math.Min(inputHeight, maxHeight));
		return (MakeEven((int)Math.Round(height * inputWidth / (double)inputHeight)), height);
	}

	private static string BuildVideoFilter((int Width, int Height) resolution, (int Width, int Height)? crop)
	{
		var filters = new List<string>();
		if (crop is { } cropSize)
		{
			filters.Add($"crop={cropSize.Width}:{cropSize.Height}");
		}

		filters.Add($"scale={resolution.Width}:{resolution.Height}");
		return string.Join(',', filters);
	}

	private static string DescribeCrop(bool disableCrop, (int Width, int Height)? requestedCropRatio, (int Width, int Height)? crop)
	{
		if (disableCrop)
		{
			return "disabled";
		}

		if (requestedCropRatio is { } ratio)
		{
			return crop is null ? $"{FormatAspectRatio(ratio)} (already matched)" : FormatAspectRatio(ratio);
		}

		return crop is null ? $"auto {FormatAspectRatio(DefaultCropRatio)} (not applied)" : $"auto {FormatAspectRatio(DefaultCropRatio)}";
	}

	private static (int Width, int Height)? GetAutoCrop(int sourceWidth, int sourceHeight, (int Width, int Height) ratio)
	{
		var sourceAspect = sourceWidth / (double)sourceHeight;
		var targetAspect = ratio.Width / (double)ratio.Height;
		if (sourceAspect < targetAspect)
		{
			return null;
		}

		var cropWidth = MakeEven(Math.Min(sourceWidth, (int)Math.Floor(sourceHeight * targetAspect)));
		return cropWidth >= sourceWidth ? null : (cropWidth, MakeEven(sourceHeight));
	}

	private static (int Width, int Height)? GetForcedCrop(int sourceWidth, int sourceHeight, (int Width, int Height) ratio)
	{
		var sourceAspect = sourceWidth / (double)sourceHeight;
		var targetAspect = ratio.Width / (double)ratio.Height;
		if (Math.Abs(sourceAspect - targetAspect) < 0.000001d)
		{
			return null;
		}

		if (sourceAspect > targetAspect)
		{
			var cropWidth = MakeEven(Math.Min(sourceWidth, (int)Math.Floor(sourceHeight * targetAspect)));
			return cropWidth >= sourceWidth ? null : (cropWidth, MakeEven(sourceHeight));
		}

		var cropHeight = MakeEven(Math.Min(sourceHeight, (int)Math.Floor(sourceWidth / targetAspect)));
		return cropHeight >= sourceHeight ? null : (MakeEven(sourceWidth), cropHeight);
	}

	private static string FormatAspectRatio((int Width, int Height) ratio) => $"{ratio.Width}:{ratio.Height}";

	private static bool TryParseAspectRatio(string value, out (int Width, int Height) ratio)
	{
		ratio = default;
		var parts = value.Split(':', 2, StringSplitOptions.TrimEntries);
		if (parts.Length != 2
			|| !int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var width)
			|| !int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)
			|| width <= 0
			|| height <= 0)
		{
			return false;
		}

		ratio = (width, height);
		return true;
	}

	private static int ComputeStartingVideoBitrateKbps(long targetBytes, double durationSeconds, int audioKbps) =>
		Math.Max(50, (int)Math.Floor(targetBytes * 8d / durationSeconds / 1000d - audioKbps - BitrateOverheadKbps));

	private static int GetMinVideoBitrateKbps(int height) => height switch
	{
		>= 1440 => 6000,
		>= 1080 => 3500,
		>= 720 => 1800,
		>= 540 => 1100,
		>= 480 => 600,
		>= 360 => 350,
		_ => 180,
	};

	private static int GetAudioBitrateKbps(int height) => height switch
	{
		<= 240 => 64,
		<= 360 => 64,
		_ => AudioKbps,
	};

	private static double? GetOutputFrameRate(double sourceFrameRate, int targetHeight) =>
		targetHeight == 240 && sourceFrameRate > 30d ? sourceFrameRate / 2d : null;

	private static double ParseFrameRate(string value)
	{
		var parts = value.Split('/', 2, StringSplitOptions.TrimEntries);
		if (parts.Length == 2
			&& double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator)
			&& double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator)
			&& denominator != 0)
		{
			return numerator / denominator;
		}

		return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var frameRate) ? frameRate : 0;
	}

	private static int MakeEven(int value) => value < 2 ? 2 : value & ~1;

	private static string? ResolveToolPath(string localName, string fallbackName)
	{
		var localPath = Path.Combine(AppContext.BaseDirectory, localName);
		return File.Exists(localPath) ? localPath : CanStart(fallbackName) ? fallbackName : null;
	}

	private static bool CanStart(string fileName)
	{
		try
		{
			Run(fileName, "-version", capture: true);
			return true;
		}
		catch
		{
			return false;
		}
	}

	private static string Run(string fileName, string arguments, bool capture = false)
	{
		var psi = new ProcessStartInfo(fileName, arguments)
		{
			UseShellExecute = false,
			CreateNoWindow = capture,
			RedirectStandardOutput = capture,
			RedirectStandardError = capture,
		};

		using var process = Process.Start(psi) ?? throw new InvalidOperationException($"Could not start {fileName}.");
		if (!capture)
		{
			process.WaitForExit();
			if (process.ExitCode != 0)
			{
				throw new InvalidOperationException($"Command failed: {fileName} {arguments}");
			}

			return string.Empty;
		}

		var stdout = process.StandardOutput.ReadToEnd();
		var stderr = process.StandardError.ReadToEnd();
		process.WaitForExit();
		return process.ExitCode == 0 ? stdout : throw new InvalidOperationException($"Command failed: {fileName} {arguments}{Environment.NewLine}{stderr}");
	}

	private static void CleanupPassLogs(string passLogBase)
	{
		var directory = Path.GetDirectoryName(passLogBase);
		if (directory is null || !Directory.Exists(directory))
		{
			return;
		}

		foreach (var file in Directory.EnumerateFiles(directory, Path.GetFileName(passLogBase) + "*").ToArray())
		{
			try
			{
				File.Delete(file);
			}
			catch
			{
			}
		}
	}

	private static string Quote(string value) => $"\"{value.Replace("\"", "\\\"")}\"";
}
