using System.IO;
using System.IO.Compression;
using System.Threading;
using ClaudeDo.Installer.Core;
using ClaudeDo.Releases;

namespace ClaudeDo.Installer.Steps;

public sealed class DownloadAndExtractStep : IInstallStep
{
    private readonly IReleaseClient _releases;
    private readonly string _cacheDir;

    public DownloadAndExtractStep(IReleaseClient releases, string? cacheDirectory = null)
    {
        _releases = releases;
        // Downloads survive a failed attempt so a retry doesn't pull ~100 MB again.
        // %TEMP% because Storage Sense ages the cache out on its own.
        _cacheDir = cacheDirectory ?? Path.Combine(Path.GetTempPath(), "ClaudeDo-download-cache");
    }

    public string Name => "Download and Extract";

    public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(ctx.InstallDirectory))
            return StepResult.Fail("Install directory is not set.");

        progress.Report("Fetching latest release metadata...");
        var release = await _releases.GetLatestReleaseAsync(ct);
        if (release is null)
            return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");

        var zipAsset = release.Assets.FirstOrDefault(a =>
            a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
            a.Name.EndsWith("-win-x64.zip", StringComparison.OrdinalIgnoreCase));
        var checksumAsset = release.Assets.FirstOrDefault(a =>
            a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));

        if (zipAsset is null)
            return StepResult.Fail("Release zip asset not found in release metadata.");
        if (checksumAsset is null)
            return StepResult.Fail("checksums.txt not found in release metadata.");

        Directory.CreateDirectory(_cacheDir);
        var zipPath = Path.Combine(_cacheDir, zipAsset.Name);
        var checksumPath = Path.Combine(_cacheDir, "checksums.txt");
        PruneCacheExcept(zipAsset.Name);

        {
            progress.Report("Downloading checksums...");
            await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
                new Progress<long>(_ => { }), ct);

            var map = ChecksumVerifier.ParseChecksumsFile(await File.ReadAllTextAsync(checksumPath, ct));
            if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
                return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");

            // An earlier attempt may have failed after the download (locked files, bad
            // extraction). Reuse that zip when it still verifies rather than re-downloading.
            if (File.Exists(zipPath) && ChecksumVerifier.Verify(zipPath, expectedHash))
            {
                progress.Report($"Reusing the already downloaded {zipAsset.Name}.");
            }
            else
            {
                var totalMb = zipAsset.Size / (1024 * 1024);
                progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
                long lastReportedMb = -1;
                await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
                    new Progress<long>(b =>
                    {
                        var mb = b / (1024 * 1024);
                        if (mb == lastReportedMb) return;
                        lastReportedMb = mb;
                        // Leading "\r" tells the UI to overwrite the previous line instead of appending.
                        progress.Report($"\r  {mb} / {totalMb} MB downloaded");
                    }),
                    ct);

                progress.Report("Verifying checksum...");
                if (!ChecksumVerifier.Verify(zipPath, expectedHash))
                {
                    // Never keep a bad download around — it would be re-verified forever.
                    TryDelete(zipPath);
                    return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
                }
            }

            // Only after verification do we touch the install directory.
            progress.Report("Stashing previous app/worker binaries...");
            var appDest = Path.Combine(ctx.InstallDirectory, "app");
            var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
            var appBak = appDest + ".bak";
            var workerBak = workerDest + ".bak";

            var stashedApp = false;
            var failedPath = ctx.InstallDirectory;
            try
            {
                failedPath = appBak;
                if (Directory.Exists(appBak))    DeleteWithRetry(appBak);
                failedPath = workerBak;
                if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
                failedPath = appDest;
                if (Directory.Exists(appDest))   { MoveWithRetry(appDest, appBak); stashedApp = true; }
                failedPath = workerDest;
                if (Directory.Exists(workerDest)) MoveWithRetry(workerDest, workerBak);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                // Undo a half-done stash: a leftover app.bak would be deleted as a stale
                // stash on the next attempt — that copy is the only one we still have.
                if (stashedApp && !Directory.Exists(appDest) && Directory.Exists(appBak))
                    try { MoveWithRetry(appBak, appDest); } catch { /* best effort */ }

                // A just-stopped app/worker (or an Explorer/terminal window sitting in
                // the install dir) still held a handle. Surface an actionable message
                // instead of the raw "process cannot access the file" error.
                return StepResult.Fail(
                    $"Could not replace '{failedPath}' — it is still in use. " +
                    "Make sure ClaudeDo is fully closed (app and worker) and no Explorer or " +
                    $"terminal window is open inside the install folder, then run the update again. Details: {ex.Message}");
            }

            progress.Report("Extracting...");
            Directory.CreateDirectory(ctx.InstallDirectory);
            try
            {
                ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
            }
            catch (Exception ex)
            {
                // Roll back to previous binaries.
                if (Directory.Exists(appDest))   DeleteWithRetry(appDest);
                if (Directory.Exists(workerDest)) DeleteWithRetry(workerDest);
                if (Directory.Exists(appBak))    MoveWithRetry(appBak, appDest);
                if (Directory.Exists(workerBak)) MoveWithRetry(workerBak, workerDest);
                return StepResult.Fail(
                    $"Extraction failed; previous binaries have been restored: {ex.Message}.");
            }

            // Success — drop stash.
            if (Directory.Exists(appBak))    DeleteWithRetry(appBak);
            if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);

            // Installed — the cached zip has served its purpose.
            TryDelete(zipPath);
            TryDelete(checksumPath);

            ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
            return StepResult.Ok();
        }
    }

    // Zips from earlier attempts on other versions would pile up otherwise.
    private void PruneCacheExcept(string keepFileName)
    {
        try
        {
            foreach (var file in Directory.EnumerateFiles(_cacheDir, "*.zip"))
            {
                if (!string.Equals(Path.GetFileName(file), keepFileName, StringComparison.OrdinalIgnoreCase))
                    TryDelete(file);
            }
        }
        catch { /* best effort */ }
    }

    private static void TryDelete(string file)
    {
        try { File.Delete(file); } catch { /* best effort */ }
    }

    private static void MoveWithRetry(string source, string dest)
        => RetryIo(() => Directory.Move(source, dest));

    private static void DeleteWithRetry(string dir)
        => RetryIo(() => Directory.Delete(dir, recursive: true));

    // WaitForExit returns before Windows releases a just-killed process's file
    // handles, so the stash Move/Delete can briefly hit a sharing violation.
    // Retry through transient IO/access errors (~5s) before letting it surface.
    private static void RetryIo(Action action)
    {
        const int attempts = 10;
        for (var i = 0; ; i++)
        {
            try { action(); return; }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException && i < attempts - 1)
            {
                Thread.Sleep(500);
            }
        }
    }
}
