using System.Diagnostics; using System.IO; using System.Net; using System.Net.Http; namespace MossyUpdater.Services { public class DownloadService : IDownloadService { private readonly HttpClient _httpClient; private readonly ILogger _logger; private const int MaxRetries = 3; private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2); public DownloadService(HttpClient httpClient, ILogger logger) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task DownloadFileAsync(string url, string destinationPath, IProgress? progress = null, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); var result = new DownloadResult(); await _logger.LogInfoAsync($"Starting download from {url} to {destinationPath}"); for (int attempt = 1; attempt <= MaxRetries; attempt++) { try { if (attempt > 1) { progress?.Report(new DownloadProgress { Status = $"Retrying download (attempt {attempt}/{MaxRetries})..." }); await _logger.LogInfoAsync($"Retry attempt {attempt}/{MaxRetries}"); await Task.Delay(RetryDelay, cancellationToken); } else { progress?.Report(new DownloadProgress { Status = "Starting download..." }); } using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); var totalBytes = response.Content.Headers.ContentLength; progress?.Report(new DownloadProgress { TotalBytes = totalBytes, Status = "Downloading..." }); var directory = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true); var buffer = new byte[8192]; long totalBytesRead = 0; int bytesRead; while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) { await fileStream.WriteAsync(buffer, 0, bytesRead, cancellationToken); totalBytesRead += bytesRead; progress?.Report(new DownloadProgress { BytesReceived = totalBytesRead, TotalBytes = totalBytes, Status = $"Downloaded {FormatBytes(totalBytesRead)}" + (totalBytes.HasValue ? $" of {FormatBytes(totalBytes.Value)}" : "") }); } result.Success = true; result.BytesDownloaded = totalBytesRead; progress?.Report(new DownloadProgress { BytesReceived = totalBytesRead, TotalBytes = totalBytes, Status = "Download completed successfully" }); await _logger.LogInfoAsync($"Download completed successfully. Downloaded {FormatBytes(totalBytesRead)} in {stopwatch.Elapsed:mm\\:ss}"); break; } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException && attempt < MaxRetries) { await _logger.LogWarningAsync($"Timeout on attempt {attempt}: {ex.Message}"); continue; } catch (OperationCanceledException) { result.ErrorMessage = "Download was cancelled"; progress?.Report(new DownloadProgress { Status = "Download cancelled" }); await _logger.LogInfoAsync("Download was cancelled by user"); if (File.Exists(destinationPath)) { try { File.Delete(destinationPath); } catch { } } break; } catch (HttpRequestException ex) when (IsRetryableHttpError(ex) && attempt < MaxRetries) { await _logger.LogWarningAsync($"Retryable error on attempt {attempt}: {ex.Message}"); continue; } catch (Exception ex) { result.ErrorMessage = GetFriendlyErrorMessage(ex); progress?.Report(new DownloadProgress { Status = $"Error: {result.ErrorMessage}" }); await _logger.LogErrorAsync($"Download failed on attempt {attempt}", ex); if (File.Exists(destinationPath)) { try { File.Delete(destinationPath); } catch { } } if (attempt >= MaxRetries) break; } } stopwatch.Stop(); result.Duration = stopwatch.Elapsed; return result; } public async Task CheckForUpdatesAsync(string url, string localFilePath, CancellationToken cancellationToken = default) { var updateInfo = new FileUpdateInfo(); try { var request = new HttpRequestMessage(HttpMethod.Head, url); using var response = await _httpClient.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { updateInfo.RemoteLastModified = response.Content.Headers.LastModified?.DateTime; updateInfo.RemoteSize = response.Content.Headers.ContentLength; if (File.Exists(localFilePath)) { var localFileInfo = new FileInfo(localFilePath); updateInfo.LocalLastModified = localFileInfo.LastWriteTime; updateInfo.LocalSize = localFileInfo.Length; updateInfo.UpdateAvailable = updateInfo.RemoteLastModified.HasValue && updateInfo.RemoteLastModified > updateInfo.LocalLastModified || (updateInfo.RemoteSize.HasValue && updateInfo.RemoteSize != updateInfo.LocalSize); } else { updateInfo.UpdateAvailable = true; } } else { updateInfo.ErrorMessage = $"Failed to check for updates: {response.StatusCode}"; } } catch (Exception ex) { updateInfo.ErrorMessage = $"Error checking for updates: {ex.Message}"; } return updateInfo; } public void UnblockFile(string filePath) { try { var zoneIdentifier = filePath + ":Zone.Identifier"; if (File.Exists(zoneIdentifier)) { File.Delete(zoneIdentifier); } } catch { } } private static string FormatBytes(long bytes) { string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; int counter = 0; decimal number = bytes; while (Math.Round(number / 1024) >= 1) { number /= 1024; counter++; } return $"{number:n1} {suffixes[counter]}"; } private static bool IsRetryableHttpError(HttpRequestException ex) { return ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase) || ex.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) || ex.Message.Contains("network", StringComparison.OrdinalIgnoreCase); } private static string GetFriendlyErrorMessage(Exception ex) { return ex switch { HttpRequestException httpEx when httpEx.Message.Contains("404") => "File not found on server", HttpRequestException httpEx when httpEx.Message.Contains("403") => "Access denied to file", HttpRequestException httpEx when httpEx.Message.Contains("timeout") => "Download timed out", UnauthorizedAccessException => "Access denied to destination folder", DirectoryNotFoundException => "Destination folder does not exist", IOException => "File operation failed", _ => ex.Message }; } } }