using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using Microsoft.Extensions.Logging;
using Polly;
using Polly.Retry;
using PSLauncher.Core.Integrity;
using PSLauncher.Models;

namespace PSLauncher.Core.Downloads;

public sealed class DownloadManager : IDownloadManager
{
    private const int BufferSize = 1 << 20; // 1 MiB
    private const int StateFlushBytesInterval = 100 * 1024 * 1024; // 100 MiB
    private const double StateFlushSecondsInterval = 5.0;

    private readonly HttpClient _http;
    private readonly IDownloadStateStore _stateStore;
    private readonly IIntegrityService _integrity;
    private readonly ILogger<DownloadManager> _logger;
    private readonly ResiliencePipeline _retryPipeline;

    public DownloadManager(
        HttpClient http,
        IDownloadStateStore stateStore,
        IIntegrityService integrity,
        ILogger<DownloadManager> logger)
    {
        _http = http;
        _stateStore = stateStore;
        _integrity = integrity;
        _logger = logger;

        _retryPipeline = new ResiliencePipelineBuilder()
            .AddRetry(new RetryStrategyOptions
            {
                MaxRetryAttempts = 6,
                BackoffType = DelayBackoffType.Exponential,
                Delay = TimeSpan.FromSeconds(1),
                MaxDelay = TimeSpan.FromSeconds(32),
                UseJitter = true,
                ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
                    ex is HttpRequestException
                       or IOException
                       or TimeoutException
                    || (ex is HttpResumableException re && re.IsTransient)),
                OnRetry = args =>
                {
                    _logger.LogWarning(
                        "Download retry #{Attempt} after {Delay} : {Error}",
                        args.AttemptNumber + 1, args.RetryDelay, args.Outcome.Exception?.Message);
                    return ValueTask.CompletedTask;
                }
            })
            .Build();
    }

    public DownloadState? GetResumableState(string version) => _stateStore.Load(version);

    public void DiscardResumableState(string version) => _stateStore.Discard(version);

    public async Task<string> DownloadAsync(
        DownloadJob job,
        IProgress<DownloadProgress>? progress,
        CancellationToken ct)
    {
        var partialPath = _stateStore.GetPartialPath(job.Version);
        var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip

        // 1. Récupère l'état existant (s'il y en a un, et qu'il correspond à la même URL/sha)
        var existing = _stateStore.Load(job.Version);
        if (existing is not null && (existing.Url != job.Url.ToString() || existing.ExpectedSha256 != job.ExpectedSha256))
        {
            _logger.LogInformation("Cached state for {Version} mismatches new job (url/sha changed) — discarding", job.Version);
            _stateStore.Discard(job.Version);
            existing = null;
        }

        long resumeFrom = 0;
        if (existing is not null && File.Exists(partialPath))
        {
            resumeFrom = new FileInfo(partialPath).Length;
            // Si la state.json est en avance par rapport au .partial réel, on s'aligne sur le .partial
            if (resumeFrom != existing.DownloadedBytes)
            {
                _logger.LogInformation(
                    "State/file size mismatch ({StateBytes} vs {FileBytes}) — using file size",
                    existing.DownloadedBytes, resumeFrom);
                existing.DownloadedBytes = resumeFrom;
            }
        }
        else if (File.Exists(partialPath))
        {
            // Fichier partiel sans state : on repart à 0 par sécurité
            File.Delete(partialPath);
            resumeFrom = 0;
        }

        // 2. Vérif espace disque (uniquement à l'initial, pas au resume)
        if (resumeFrom == 0 && job.ExpectedSize > 0)
        {
            var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
            var needed = (long)(job.ExpectedSize * 1.05);
            if (drive.AvailableFreeSpace < needed)
                throw new IOException(
                    $"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
        }

        var state = existing ?? new DownloadState
        {
            Version = job.Version,
            Url = job.Url.ToString(),
            TotalBytes = job.ExpectedSize,
            DownloadedBytes = 0,
            ExpectedSha256 = job.ExpectedSha256,
            PartialPath = partialPath,
            StartedAtUtc = DateTime.UtcNow,
            LastChunkAtUtc = DateTime.UtcNow,
        };

        // 3. Tentatives avec retry — chaque tentative continue depuis la taille actuelle du .partial
        await _retryPipeline.ExecuteAsync(async innerCt =>
        {
            await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false);
        }, ct).ConfigureAwait(false);

        // 4. Vérif taille finale
        var actualSize = new FileInfo(partialPath).Length;
        if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
        {
            _stateStore.Discard(job.Version);
            throw new InvalidDataException(
                $"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
        }

        // 5. Vérif SHA-256
        if (!string.IsNullOrEmpty(job.ExpectedSha256) &&
            !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
        {
            _logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
            var sha = await _integrity.ComputeSha256Async(partialPath, null, ct).ConfigureAwait(false);
            if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
            {
                _stateStore.Discard(job.Version);
                throw new InvalidDataException(
                    $"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
            }
        }
        else
        {
            _logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
        }

        if (File.Exists(finalPath)) File.Delete(finalPath);
        File.Move(partialPath, finalPath);
        _stateStore.Discard(job.Version); // efface aussi le state.json puisqu'on a fini
        return finalPath;
    }

    private async Task DownloadChunkAsync(
        DownloadJob job,
        DownloadState state,
        string partialPath,
        IProgress<DownloadProgress>? progress,
        CancellationToken ct)
    {
        var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0;
        if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return; // déjà tout là

        using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
        if (resumeFrom > 0)
        {
            req.Headers.Range = new RangeHeaderValue(resumeFrom, null);
            // If-Range : si l'ETag du serveur a changé entre temps, on veut recommencer proprement
            if (!string.IsNullOrEmpty(state.Etag))
            {
                req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
            }
            else if (!string.IsNullOrEmpty(state.LastModified) &&
                     DateTimeOffset.TryParse(state.LastModified, out var lm))
            {
                req.Headers.IfRange = new RangeConditionHeaderValue(lm);
            }
        }

        _logger.LogInformation("HTTP GET {Url} range={Resume}-", job.Url, resumeFrom);
        using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);

        if (resumeFrom > 0 && resp.StatusCode == HttpStatusCode.OK)
        {
            // Le serveur a renvoyé 200 alors qu'on demandait du Range : la ressource a changé.
            _logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh");
            resp.Dispose();
            File.Delete(partialPath);
            _stateStore.Discard(job.Version);
            // On retry sera traité par Polly via une exception
            throw new HttpResumableException("Resource changed on server, restart", isTransient: true);
        }

        // 5xx / 408 / 429 → exception transitoire pour Polly
        if (!resp.IsSuccessStatusCode)
        {
            var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
            throw new HttpResumableException(
                $"HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}",
                isTransient: transient);
        }

        // Capture ETag et Last-Modified du serveur si présents (pour les futures reprises)
        if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
        if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R");

        // Total : si le serveur ne fournit pas Content-Length, on garde la valeur du manifest
        var contentLength = resp.Content.Headers.ContentLength ?? 0;
        var total = state.TotalBytes;
        if (resp.StatusCode == HttpStatusCode.PartialContent && resp.Content.Headers.ContentRange is { Length: { } cl })
            total = cl;
        else if (resp.StatusCode == HttpStatusCode.OK && contentLength > 0)
            total = contentLength;
        if (total > 0) state.TotalBytes = total;

        // Si on est sur un 200 alors qu'on partait de 0, on écrit en append (FileMode.Append depuis position 0)
        // Si on est sur un 206 avec resumeFrom>0, on append.
        var mode = resumeFrom > 0 ? FileMode.Append : FileMode.Create;
        await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
        await using var dst = new FileStream(partialPath, mode, FileAccess.Write, FileShare.None, BufferSize, useAsync: true);

        var buffer = new byte[BufferSize];
        long read = resumeFrom;
        var sw = Stopwatch.StartNew();
        var lastReport = sw.Elapsed;
        long lastReportBytes = read;
        var lastFlush = sw.Elapsed;
        long lastFlushBytes = read;

        int n;
        while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
        {
            await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
            read += n;
            state.DownloadedBytes = read;
            state.LastChunkAtUtc = DateTime.UtcNow;

            var elapsed = sw.Elapsed;

            // Progression UI
            if ((elapsed - lastReport).TotalMilliseconds >= 250)
            {
                var deltaBytes = read - lastReportBytes;
                var deltaSeconds = (elapsed - lastReport).TotalSeconds;
                var bps = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0;
                TimeSpan? eta = null;
                if (bps > 0 && state.TotalBytes > 0) eta = TimeSpan.FromSeconds((state.TotalBytes - read) / bps);
                progress?.Report(new DownloadProgress(read, state.TotalBytes, bps, eta));
                lastReport = elapsed;
                lastReportBytes = read;
            }

            // Persist state.json toutes les 5s ou tous les 100 Mo
            if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
                read - lastFlushBytes >= StateFlushBytesInterval)
            {
                await dst.FlushAsync(ct).ConfigureAwait(false);
                _stateStore.Save(state);
                lastFlush = elapsed;
                lastFlushBytes = read;
            }
        }

        await dst.FlushAsync(ct).ConfigureAwait(false);
        _stateStore.Save(state);
        progress?.Report(new DownloadProgress(read, state.TotalBytes, 0, TimeSpan.Zero));
    }
}

/// <summary>Exception interne pour signaler à Polly qu'on doit retry.</summary>
internal sealed class HttpResumableException : Exception
{
    public bool IsTransient { get; }
    public HttpResumableException(string message, bool isTransient) : base(message)
    {
        IsTransient = isTransient;
    }
}
