using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace ModpackLauncher.Services;

/// <summary>
/// In-place launcher self-update. Pattern matches Chrome/VS Code/Discord:
///   1. Download new exe to "<current>.new"
///   2. Rename running exe to "<current>.old" (Windows allows MoveFile on a running exe)
///   3. Rename ".new" to the canonical name
///   4. Process.Start the new exe with our args
///   5. Exit -- the OS releases the .old file once the old process is gone
///   6. Next launcher start deletes any leftover .old via CleanupAfterUpdate()
///
/// Fails loudly (throws) on any step. Caller falls back to browser-open download.
/// </summary>
public sealed class SelfUpdateService
{
    private static readonly HttpClient _http = new()
    {
        Timeout = TimeSpan.FromMinutes(5),
        DefaultRequestVersion = new Version(2, 0),
    };

    public static string CurrentExePath =>
        Environment.ProcessPath
        ?? throw new InvalidOperationException("Environment.ProcessPath was null (self-update needs a real exe path).");

    public static string OldExePath => CurrentExePath + ".old";
    public static string NewExePath => CurrentExePath + ".new";

    /// <summary>
    /// Delete any leftover .old file from a prior self-update. Safe to call on every startup;
    /// quietly retries next start if the file is still locked (rare timing edge case).
    /// </summary>
    public static void CleanupAfterUpdate(Action<string>? log = null)
    {
        var old = OldExePath;
        if (!File.Exists(old)) return;
        try
        {
            File.Delete(old);
            log?.Invoke($"[update] Removed previous launcher backup: {Path.GetFileName(old)}");
        }
        catch (Exception ex)
        {
            // The old process may still be releasing the file handle. Not fatal --
            // we'll retry next start. Worst case the .old file sits there harmlessly.
            log?.Invoke($"[update] Couldn't remove {Path.GetFileName(old)} yet ({ex.GetType().Name}); will retry next start.");
        }
    }

    /// <summary>
    /// Download the new launcher, swap it in, restart with the same command line, and exit.
    /// Returns only on failure (i.e. throws) -- on success the current process exits.
    /// </summary>
    public async Task DownloadAndInstallAsync(
        string downloadUrl,
        IProgress<(long bytesReceived, long? totalBytes)>? progress = null,
        Action<string>? log = null,
        CancellationToken ct = default)
    {
        var curPath = CurrentExePath;
        var newPath = NewExePath;
        var oldPath = OldExePath;

        log?.Invoke($"[update] Downloading {downloadUrl}");

        // 1. Download to .new, replacing any stale partial from a failed prior attempt
        if (File.Exists(newPath)) File.Delete(newPath);

        using (var resp = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct))
        {
            resp.EnsureSuccessStatusCode();
            var total = resp.Content.Headers.ContentLength;
            await using var src = await resp.Content.ReadAsStreamAsync(ct);
            await using var dst = new FileStream(newPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);

            var buffer = new byte[81920];
            long received = 0;
            int read;
            while ((read = await src.ReadAsync(buffer, ct)) > 0)
            {
                await dst.WriteAsync(buffer.AsMemory(0, read), ct);
                received += read;
                progress?.Report((received, total));
            }
            log?.Invoke($"[update] Downloaded {received:N0} bytes");
        }

        // Sanity: refuse to swap in something laughably small (a captive portal
        // intercepted us, or the server returned an error body as 200 OK).
        var fi = new FileInfo(newPath);
        if (fi.Length < 1_000_000)
        {
            File.Delete(newPath);
            throw new InvalidDataException($"Downloaded launcher is only {fi.Length:N0} bytes -- almost certainly not the real binary.");
        }

        // 2. Move running exe to .old (this works while we're executing — NTFS resolves
        //    running processes by handle, not by path).
        if (File.Exists(oldPath)) File.Delete(oldPath);
        File.Move(curPath, oldPath);
        log?.Invoke($"[update] Backed up current launcher to {Path.GetFileName(oldPath)}");

        // 3. Move new exe into the canonical slot
        File.Move(newPath, curPath);
        log?.Invoke($"[update] New launcher staged at {Path.GetFileName(curPath)}");

        // 4. Build the restart command. Pass through any args the current process was
        //    launched with (e.g. URI handler args from a future deep-link integration),
        //    skipping argv[0] which is the exe itself.
        var args = Environment.GetCommandLineArgs();
        var psi = new ProcessStartInfo
        {
            FileName        = curPath,
            UseShellExecute = false,
            WorkingDirectory = Path.GetDirectoryName(curPath) ?? Environment.CurrentDirectory,
        };
        for (int i = 1; i < args.Length; i++) psi.ArgumentList.Add(args[i]);

        log?.Invoke("[update] Restarting with the new launcher...");
        Process.Start(psi);

        // Tiny grace period so the spawn has actually entered the kernel before we
        // pull our pid out from under it. 100 ms is more than enough.
        await Task.Delay(100, CancellationToken.None);

        // Hard exit. We don't go through Avalonia shutdown -- Application.Lifetime.Exit()
        // can hang on window close handlers, and we DO want to release our exe handle
        // promptly so the new process's CleanupAfterUpdate() can delete our .old file.
        Environment.Exit(0);
    }
}
