using Sonex.Data.Records; using System.Net; using WorkerCore = Sonex.Library.WorkersCore.Worker; namespace Sonex.Worker.WebSync; internal sealed class WebSyncImageSynchronizer { private static readonly HttpClient ImageHttpClient = CreateImageHttpClient(); private const string OperationImageTemplateParsing = "ImageTemplateParsing"; private const string OperationImageHeadersRead = "ImageHeadersRead"; private const string OperationImageDownload = "ImageDownload"; private const string OperationImageMetadataUpdate = "ImageMetadataUpdate"; private const int SourceSize = 290; private const int ThumbnailSize = 95; public async Task UpdateAsync( string articleNumber, IReadOnlyList imageUrls, string targetImagesPath, WebSyncRunReport runReport, Func waitIfPaused, CancellationToken cancellationToken) { string normalizedArticleNumber = NormalizeArticleNumber(articleNumber); if (string.IsNullOrWhiteSpace(normalizedArticleNumber)) return; string targetRootPath = NormalizeTargetRootPath(targetImagesPath); if (string.IsNullOrWhiteSpace(targetRootPath)) return; var templates = new List(); int imageIndex = 0; foreach (string imageUrl in imageUrls) { cancellationToken.ThrowIfCancellationRequested(); await waitIfPaused(cancellationToken).ConfigureAwait(false); imageIndex++; if (!TryBuildTemplate(imageUrl, imageIndex, out ImageTemplateInfo template)) continue; templates.Add(template); } if (imageUrls.Count > 0 && templates.Count == 0) { string message = $"Image template parsing failed for article {normalizedArticleNumber}."; WorkerCore.LogError( message, new InvalidOperationException(message), OperationImageTemplateParsing); return; } runReport.IncrementImageFound(templates.Count); var expectedSourceFiles = new HashSet(StringComparer.OrdinalIgnoreCase); var expectedThumbnailFiles = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (ImageTemplateInfo template in templates) { cancellationToken.ThrowIfCancellationRequested(); await waitIfPaused(cancellationToken).ConfigureAwait(false); string sourceFilePath = BuildImagePath( targetRootPath, SourceSize, normalizedArticleNumber, template.ImageNo, template.Extension); string thumbnailFilePath = BuildImagePath( targetRootPath, ThumbnailSize, normalizedArticleNumber, template.ImageNo, template.Extension); expectedSourceFiles.Add(Path.GetFileName(sourceFilePath)); expectedThumbnailFiles.Add(Path.GetFileName(thumbnailFilePath)); await UpdateSingleImageAsync( template, sourceFilePath, thumbnailFilePath, runReport, waitIfPaused, cancellationToken).ConfigureAwait(false); } cancellationToken.ThrowIfCancellationRequested(); await waitIfPaused(cancellationToken).ConfigureAwait(false); CleanupStaleFiles(targetRootPath, SourceSize, normalizedArticleNumber, expectedSourceFiles); CleanupStaleFiles(targetRootPath, ThumbnailSize, normalizedArticleNumber, expectedThumbnailFiles); var imageRecords = templates.Select(template => new ProductWebImageRecord { ArticleNumber = normalizedArticleNumber, ImageNo = template.ImageNo, ImageUrlTemplate = template.TemplateUrl }).ToArray(); var replaceResult = await WorkerCore.ExecuteDatabaseSingleWithRetryAsync( ct => ProductWebImageRecord.ReplaceArticle( normalizedArticleNumber, imageRecords, ct), OperationImageMetadataUpdate, cancellationToken).ConfigureAwait(false); if (!replaceResult.Success) { string error = replaceResult.ErrorMessage ?? "Unknown image metadata update error."; throw new WebSyncOperationException( OperationImageMetadataUpdate, $"Failed to update product_web_images for {normalizedArticleNumber}. Error={error}", new InvalidOperationException(error)); } } private static async Task UpdateSingleImageAsync( ImageTemplateInfo template, string sourceFilePath, string thumbnailFilePath, WebSyncRunReport runReport, Func waitIfPaused, CancellationToken cancellationToken) { await waitIfPaused(cancellationToken).ConfigureAwait(false); bool thumbnailChanged = await EnsureImageFileAsync( template.ThumbnailUrl, thumbnailFilePath, runReport, waitIfPaused, cancellationToken).ConfigureAwait(false); if (thumbnailChanged || !File.Exists(sourceFilePath)) { await waitIfPaused(cancellationToken).ConfigureAwait(false); await EnsureImageFileAsync( template.SourceUrl, sourceFilePath, runReport, waitIfPaused, cancellationToken).ConfigureAwait(false); } } private static async Task EnsureImageFileAsync( string imageUrl, string destinationFilePath, WebSyncRunReport runReport, Func waitIfPaused, CancellationToken cancellationToken) { await waitIfPaused(cancellationToken).ConfigureAwait(false); if (File.Exists(destinationFilePath)) { long localLength = new FileInfo(destinationFilePath).Length; long? remoteLength = await GetRemoteContentLengthAsync( imageUrl, waitIfPaused, cancellationToken).ConfigureAwait(false); if (remoteLength.HasValue && remoteLength.Value > 0 && localLength == remoteLength.Value) { return false; } } await DownloadImageAsync(imageUrl, destinationFilePath, runReport, waitIfPaused, cancellationToken).ConfigureAwait(false); return true; } private static async Task GetRemoteContentLengthAsync( string imageUrl, Func waitIfPaused, CancellationToken cancellationToken) { try { await waitIfPaused(cancellationToken).ConfigureAwait(false); using var request = CreateGetRequest(imageUrl); using HttpResponseMessage response = await ImageHttpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return response.Content.Headers.ContentLength; } catch (Exception ex) when (ex is not OperationCanceledException) { throw new WebSyncOperationException( OperationImageHeadersRead, $"Image headers read failed for {imageUrl}.", ex); } } private static async Task DownloadImageAsync( string imageUrl, string destinationFilePath, WebSyncRunReport runReport, Func waitIfPaused, CancellationToken cancellationToken) { try { await waitIfPaused(cancellationToken).ConfigureAwait(false); byte[] bytes = await DownloadImageBytesAsync(imageUrl, waitIfPaused, cancellationToken).ConfigureAwait(false); await SaveImageAsync(destinationFilePath, bytes, cancellationToken).ConfigureAwait(false); runReport.IncrementImageDownloaded(); runReport.IncrementImageUpdated(); } catch (Exception ex) when (ex is not OperationCanceledException) { throw new WebSyncOperationException( OperationImageDownload, $"Image download failed for {imageUrl}.", ex); } } private static async Task DownloadImageBytesAsync( string imageUrl, Func waitIfPaused, CancellationToken cancellationToken) { await waitIfPaused(cancellationToken).ConfigureAwait(false); using var request = CreateGetRequest(imageUrl); using HttpResponseMessage response = await ImageHttpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); } private static async Task SaveImageAsync( string destinationFilePath, byte[] bytes, CancellationToken cancellationToken) { string? directoryPath = Path.GetDirectoryName(destinationFilePath); if (!string.IsNullOrWhiteSpace(directoryPath)) Directory.CreateDirectory(directoryPath); await File.WriteAllBytesAsync(destinationFilePath, bytes, cancellationToken).ConfigureAwait(false); } private static HttpClient CreateImageHttpClient() { return new HttpClient { DefaultRequestVersion = HttpVersion.Version20, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact }; } private static HttpRequestMessage CreateGetRequest(string imageUrl) { return new HttpRequestMessage(HttpMethod.Get, imageUrl) { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionExact }; } private static void CleanupStaleFiles( string targetRootPath, int size, string articleNumber, HashSet expectedFileNames) { string articleDirectoryPath = Path.Combine(targetRootPath, size.ToString(), articleNumber); if (!Directory.Exists(articleDirectoryPath)) return; foreach (string filePath in Directory.GetFiles(articleDirectoryPath)) { string fileName = Path.GetFileName(filePath); if (expectedFileNames.Contains(fileName)) continue; try { File.Delete(filePath); } catch { } } if (expectedFileNames.Count == 0 && !Directory.EnumerateFileSystemEntries(articleDirectoryPath).Any()) { try { Directory.Delete(articleDirectoryPath); } catch { } } } private static string BuildImagePath( string targetRootPath, int size, string articleNumber, int imageNo, string extension) { string fileName = $"{articleNumber}_{imageNo}{extension}"; return Path.Combine(targetRootPath, size.ToString(), articleNumber, fileName); } private static string NormalizeTargetRootPath(string targetImagesPath) { if (string.IsNullOrWhiteSpace(targetImagesPath)) return string.Empty; string normalized = targetImagesPath.Trim(); try { return Path.GetFullPath(normalized); } catch { return normalized; } } private static string NormalizeArticleNumber(string articleNumber) { return string.IsNullOrWhiteSpace(articleNumber) ? string.Empty : articleNumber.Trim(); } private static bool TryBuildTemplate( string imageUrl, int imageNo, out ImageTemplateInfo templateInfo) { templateInfo = null!; if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out Uri? uri)) return false; string[] segments = uri.AbsolutePath .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (segments.Length < 5) return false; if (!string.Equals(segments[0], "pub", StringComparison.OrdinalIgnoreCase) || !string.Equals(segments[1], "cdn", StringComparison.OrdinalIgnoreCase)) { return false; } string articleSegment = segments[2]; string sizeSegment = segments[3]; string fileName = segments[4]; if (string.IsNullOrWhiteSpace(articleSegment) || string.IsNullOrWhiteSpace(sizeSegment) || string.IsNullOrWhiteSpace(fileName)) { return false; } string extension = Path.GetExtension(fileName); if (string.IsNullOrWhiteSpace(extension)) extension = ".jpg"; string templatePath = $"/pub/cdn/{articleSegment}/{{size}}/{fileName}"; string templateUrl = $"{uri.Scheme}://{uri.Authority}{templatePath}"; string sourceUrl = templateUrl.Replace("{size}", SourceSize.ToString(), StringComparison.Ordinal); string thumbnailUrl = templateUrl.Replace("{size}", ThumbnailSize.ToString(), StringComparison.Ordinal); templateInfo = new ImageTemplateInfo { ImageNo = imageNo, TemplateUrl = templateUrl, SourceUrl = sourceUrl, ThumbnailUrl = thumbnailUrl, Extension = extension }; return true; } private sealed class ImageTemplateInfo { public int ImageNo { get; init; } public string TemplateUrl { get; init; } = string.Empty; public string SourceUrl { get; init; } = string.Empty; public string ThumbnailUrl { get; init; } = string.Empty; public string Extension { get; init; } = ".jpg"; } }