using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Globalization; using Catalog.Communication.Abstractions; using Catalog.Communication.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Catalog.Communication; public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClient { private const string AdminMenuPath = "admin/menu/Menu4.abl"; private const string PublicLogonPath = "Logon.abl"; private const string UsersPath = "Users.abl"; private const string Foto2Path = "Foto2.abl"; private const string ThumbnailPath = "foto"; private const string OriginalPath = "fotoOriginali"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true, }; private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly IOptions _options; public RaceUploadCommunicationClient( HttpClient httpClient, IOptions options, ILogger logger) { _httpClient = httpClient; _options = options; _logger = logger; } public Task LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var formFields = new Dictionary { ["login"] = request.Login, ["pwd"] = request.Password, ["cmdIU"] = request.Command, }; return PostFormAsync(AdminMenuPath, formFields, "admin-login", cancellationToken); } public Task LogoutAdminAsync(CancellationToken cancellationToken = default) { var formFields = new Dictionary { ["cmdIU"] = "login", }; return PostFormAsync(AdminMenuPath, formFields, "admin-logout", cancellationToken); } public Task UploadRaceImageAsync(RaceImageUploadRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); return PostMultipartAndParseUploadAsync( GetAdminPagePath("Gara"), "gara-upload-image", request.FileStream, request.FileName, request.FormFieldName, request.ContentType, static fields => { fields["cmd"] = "loadImg"; }, new Dictionary { ["id"] = request.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), ["codImage"] = request.CodImage.ToString(System.Globalization.CultureInfo.InvariantCulture), ["totImgNumber"] = request.TotImgNumber?.ToString(System.Globalization.CultureInfo.InvariantCulture), }, cancellationToken); } public Task RemoveRaceImageAsync(RaceImageRemoveRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var fields = new Dictionary { ["cmd"] = "removeImg", ["id"] = request.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), ["codImage"] = request.CodImage.ToString(System.Globalization.CultureInfo.InvariantCulture), ["totImgNumber"] = request.TotImgNumber?.ToString(System.Globalization.CultureInfo.InvariantCulture), }; return PostFormAndParseUploadAsync(GetAdminPagePath("Gara"), fields, "gara-remove-image", cancellationToken); } public Task UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); return PostMultipartAndParseUploadAsync( GetAdminPagePath("Gara"), "gara-upload-file", request.FileStream, request.FileName, request.FormFieldName, request.ContentType, static fields => { fields["cmd"] = "saveFile"; }, new Dictionary { ["codFile"] = request.CodFile?.ToString(System.Globalization.CultureInfo.InvariantCulture), ["id"] = request.Id?.ToString(System.Globalization.CultureInfo.InvariantCulture), }, cancellationToken); } public Task SaveRaceAsync(RaceSaveRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var formFields = new Dictionary { ["cmd"] = "asq", ["act"] = "save", ["id_gara"] = request.IdGara.ToString(CultureInfo.InvariantCulture), ["descrizione"] = request.Description, ["dataGaraInizio"] = request.StartDate.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture), ["dataGaraFine"] = (request.EndDate ?? request.StartDate).ToString("dd/MM/yyyy", CultureInfo.InvariantCulture), ["id_tipoGara"] = request.TipoGaraId.ToString(CultureInfo.InvariantCulture), ["flgEventoInLinea"] = request.EventoInLinea.ToString(CultureInfo.InvariantCulture), ["flgTipoIndex"] = request.TipoIndicizzazione.ToString(CultureInfo.InvariantCulture), ["flgFree"] = request.FreeEvent.ToString(CultureInfo.InvariantCulture), ["pathBase"] = request.PathBase, ["localita"] = request.Localita, ["codGara"] = request.CodGara?.ToString(CultureInfo.InvariantCulture), }; return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-save", cancellationToken); } public Task CreateRacePointsAsync(long raceId, CancellationToken cancellationToken = default) { var formFields = new Dictionary { ["cmd"] = "creaPuntiFoto", ["id_gara"] = raceId.ToString(CultureInfo.InvariantCulture), }; return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-create-points", cancellationToken); } public Task IndexRacePointAsync(long pointId, CancellationToken cancellationToken = default) { var formFields = new Dictionary { ["cmd"] = "indexFoto", ["id_puntoFotoIdx"] = pointId.ToString(CultureInfo.InvariantCulture), }; return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-index-point", cancellationToken); } public Task GetRaceDetailAsync(long raceId, CancellationToken cancellationToken = default) { var formFields = new Dictionary { ["cmd"] = "search", ["id_gara"] = raceId.ToString(CultureInfo.InvariantCulture), }; return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-detail", cancellationToken); } public async Task UploadFileToReceiverAsync(ReceiveFileUploadRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(request.FileName); ArgumentException.ThrowIfNullOrWhiteSpace(request.DestinationPath); ArgumentNullException.ThrowIfNull(request.FileStream); var payload = await ReadAllBytesAsync(request.FileStream, cancellationToken).ConfigureAwait(false); var query = new Dictionary { ["name"] = request.FileName, ["path"] = request.DestinationPath, ["overwriteRemoteFile"] = request.OverwriteRemoteFile ? "true" : "false", ["bs"] = request.BufferSize?.ToString(CultureInfo.InvariantCulture), }; var path = AppendQuery(GetReceiveFilePath(), query); return await ExecuteWithResilienceAsync( () => { var byteContent = new ByteArrayContent(payload); byteContent.Headers.ContentType = new MediaTypeHeaderValue(request.ContentType ?? "application/octet-stream"); return new HttpRequestMessage(HttpMethod.Post, path) { Content = byteContent, }; }, ToRawResponseAsync, "receiver-upload", cancellationToken).ConfigureAwait(false); } public Task ExecuteGaraCommandAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default) { return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-command", cancellationToken); } public Task ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary formFields, CancellationToken cancellationToken = default) { var path = endpoint switch { AdminPhotoEndpoint.Foto => GetAdminPagePath("Foto"), AdminPhotoEndpoint.TipoGara => GetAdminPagePath("TipoGara"), AdminPhotoEndpoint.LogFoto => GetAdminPagePath("LogFoto"), _ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint, "Unsupported endpoint."), }; return PostFormAsync(path, formFields, $"photo-admin-{endpoint}", cancellationToken); } public Task ExecutePublicLogonAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default) { return PostFormAsync(PublicLogonPath, formFields, "public-logon", cancellationToken); } public Task ExecuteUsersAsync(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default) { return SendCommandAsync(method, UsersPath, formFields, "public-users", cancellationToken); } public Task ExecuteFoto2Async(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default) { return SendCommandAsync(method, Foto2Path, formFields, "public-foto2", cancellationToken); } public Task DownloadThumbnailAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(filename); return DownloadFileAsync(ThumbnailPath, filename, idFoto, "media-thumbnail", cancellationToken); } public Task DownloadOriginalAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(filename); return DownloadFileAsync(OriginalPath, filename, idFoto, "media-original", cancellationToken); } private Task SendCommandAsync(HttpMethod method, string path, IReadOnlyDictionary? formFields, string operationName, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(method); if (method == HttpMethod.Get) { var relativePath = AppendQuery(path, formFields); return GetAsync(relativePath, operationName, cancellationToken); } if (method == HttpMethod.Post) { return PostFormAsync(path, formFields ?? new Dictionary(), operationName, cancellationToken); } throw new NotSupportedException($"Only GET and POST are supported. Requested method: {method}."); } private Task GetAsync(string relativePath, string operationName, CancellationToken cancellationToken) { return ExecuteWithResilienceAsync( () => new HttpRequestMessage(HttpMethod.Get, relativePath), ToRawResponseAsync, operationName, cancellationToken); } private Task PostFormAsync(string relativePath, IReadOnlyDictionary formFields, string operationName, CancellationToken cancellationToken) { return ExecuteWithResilienceAsync( () => { var request = new HttpRequestMessage(HttpMethod.Post, relativePath) { Content = BuildFormContent(formFields), }; return request; }, ToRawResponseAsync, operationName, cancellationToken); } private Task PostFormAndParseUploadAsync(string relativePath, IReadOnlyDictionary formFields, string operationName, CancellationToken cancellationToken) { return ExecuteWithResilienceAsync( () => { var request = new HttpRequestMessage(HttpMethod.Post, relativePath) { Content = BuildFormContent(formFields), }; return request; }, async (response, token) => { var raw = await ToRawResponseAsync(response, token).ConfigureAwait(false); return ParseSingleItemArray(raw.Body); }, operationName, cancellationToken); } private async Task PostMultipartAndParseUploadAsync( string relativePath, string operationName, Stream fileStream, string fileName, string formFieldName, string? contentType, Action> configureRequiredFields, IReadOnlyDictionary optionalFields, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fileStream); ArgumentException.ThrowIfNullOrWhiteSpace(fileName); ArgumentException.ThrowIfNullOrWhiteSpace(formFieldName); var payload = await ReadAllBytesAsync(fileStream, cancellationToken).ConfigureAwait(false); return await ExecuteWithResilienceAsync( () => { var multipart = new MultipartFormDataContent(); var fields = new Dictionary(); configureRequiredFields(fields); foreach (var field in fields) { if (string.IsNullOrWhiteSpace(field.Value)) { continue; } multipart.Add(new StringContent(field.Value, Encoding.UTF8), field.Key); } foreach (var field in optionalFields) { if (string.IsNullOrWhiteSpace(field.Value)) { continue; } multipart.Add(new StringContent(field.Value, Encoding.UTF8), field.Key); } var fileContent = new ByteArrayContent(payload); fileContent.Headers.ContentType = new MediaTypeHeaderValue(contentType ?? "application/octet-stream"); multipart.Add(fileContent, formFieldName, fileName); return new HttpRequestMessage(HttpMethod.Post, relativePath) { Content = multipart, }; }, async (response, token) => { var raw = await ToRawResponseAsync(response, token).ConfigureAwait(false); return ParseSingleItemArray(raw.Body); }, operationName, cancellationToken).ConfigureAwait(false); } private Task DownloadFileAsync(string basePath, string filename, long? idFoto, string operationName, CancellationToken cancellationToken) { var escapedFileName = Uri.EscapeDataString(filename); var relativePath = idFoto.HasValue ? $"{basePath}/{escapedFileName}?id_foto={idFoto.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}" : $"{basePath}/{escapedFileName}"; return ExecuteWithResilienceAsync( () => new HttpRequestMessage(HttpMethod.Get, relativePath), async (response, token) => { var content = await response.Content.ReadAsByteArrayAsync(token).ConfigureAwait(false); return new MediaFileResponse { StatusCode = response.StatusCode, Content = content, ContentType = response.Content.Headers.ContentType?.MediaType, FileName = response.Content.Headers.ContentDisposition?.FileNameStar ?? response.Content.Headers.ContentDisposition?.FileName, Headers = BuildHeaders(response), }; }, operationName, cancellationToken); } private async Task ExecuteWithResilienceAsync( Func requestFactory, Func> responseFactory, string operationName, CancellationToken cancellationToken) { var options = _options.Value; Exception? lastException = null; for (var attempt = 0; attempt <= options.RetryCount; attempt++) { using var request = requestFactory(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(options.RequestTimeout); HttpResponseMessage? response = null; try { response = await _httpClient .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token) .ConfigureAwait(false); if (IsRetryableStatusCode(response.StatusCode) && attempt < options.RetryCount) { _logger.LogWarning( "Operation {OperationName} received retryable status code {StatusCode} at attempt {Attempt}/{MaxAttempts}.", operationName, (int)response.StatusCode, attempt + 1, options.RetryCount + 1); await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false); continue; } return await responseFactory(response, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { lastException = ex; if (attempt < options.RetryCount) { _logger.LogWarning(ex, "Operation {OperationName} timed out at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1); await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false); continue; } _logger.LogError(ex, "Operation {OperationName} timed out after {MaxAttempts} attempts.", operationName, options.RetryCount + 1); throw; } catch (HttpRequestException ex) { lastException = ex; if (attempt < options.RetryCount) { _logger.LogWarning(ex, "Operation {OperationName} failed with transient HTTP error at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1); await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false); continue; } _logger.LogError(ex, "Operation {OperationName} failed with HTTP error after {MaxAttempts} attempts.", operationName, options.RetryCount + 1); throw; } catch (Exception ex) { _logger.LogError(ex, "Operation {OperationName} failed with unexpected error at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1); throw; } finally { response?.Dispose(); } } throw new HttpRequestException($"Operation '{operationName}' failed after {options.RetryCount + 1} attempts.", lastException); } private static bool IsRetryableStatusCode(HttpStatusCode statusCode) { return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout or HttpStatusCode.InternalServerError; } private static async Task DelayBeforeRetryAsync(CatalogCommunicationOptions options, int attempt, CancellationToken cancellationToken) { var delay = TimeSpan.FromMilliseconds(options.RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } private static FormUrlEncodedContent BuildFormContent(IReadOnlyDictionary formFields) { var pairs = formFields .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) .Select(kvp => new KeyValuePair(kvp.Key, kvp.Value!)); return new FormUrlEncodedContent(pairs); } private static string AppendQuery(string path, IReadOnlyDictionary? query) { if (query is null || query.Count == 0) { return path; } var encodedPairs = query .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) .Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value!)}") .ToArray(); if (encodedPairs.Length == 0) { return path; } var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?"; return string.Concat(path, separator, string.Join("&", encodedPairs)); } private static async Task ToRawResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return new RawEndpointResponse { StatusCode = response.StatusCode, Body = body, Headers = BuildHeaders(response), }; } private static IReadOnlyDictionary> BuildHeaders(HttpResponseMessage response) { var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var header in response.Headers) { headers[header.Key] = header.Value.ToArray(); } foreach (var header in response.Content.Headers) { headers[header.Key] = header.Value.ToArray(); } return headers; } private static TUpload? ParseSingleItemArray(string json) { if (string.IsNullOrWhiteSpace(json)) { return default; } var items = JsonSerializer.Deserialize>(json, JsonOptions); return items is { Count: > 0 } ? items[0] : default; } private static async Task ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken) { if (stream.CanSeek) { stream.Position = 0; } using var memoryStream = new MemoryStream(); await stream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); return memoryStream.ToArray(); } private string GetAdminPagePath(string pageName) { var basePath = _options.Value.AdminPageBasePath.Trim('/'); return $"{basePath}/{pageName}.abl"; } private string GetReceiveFilePath() { var value = _options.Value.ReceiveFilePath; if (string.IsNullOrWhiteSpace(value)) { return "ReceiveFile.abl"; } return value.Trim(); } }