using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using System.Text.Json;
namespace Gateway.Services;
///
/// Handles downloading images from source URLs and storing them in Azure Blob Storage.
/// Used when processing Creative drafts to ensure all image URLs are permanent.
///
/// Blob structure: {clientId}/drafts/{draftId}/{orientation}.{ext}
/// Example: client-42/drafts/a1b2c3d4e5f6/landscape.jpg
///
/// This structure enables:
/// - Client isolation (easy to list/delete all client assets)
/// - Draft organization (images grouped per draft)
/// - Future expansion (campaigns, versions, etc.)
/// - Per-client access control via SAS tokens if needed
///
public class ImageStorageService
{
private readonly BlobServiceClient _blobClient;
private readonly IHttpClientFactory _httpFactory;
private readonly ILogger _logger;
private readonly string _containerName;
private readonly string _blobBaseUrl;
private readonly bool _isConfigured;
public ImageStorageService(
IHttpClientFactory httpFactory,
ILogger logger,
IConfiguration config,
BlobServiceClient blobClient)
{
_httpFactory = httpFactory;
_logger = logger;
_blobClient = blobClient;
_containerName = config["BlobStorage:ContainerName"] ?? "creative-images";
_blobBaseUrl = config["BlobStorage:BaseUrl"] ?? "https://usimadpcreatives.blob.core.windows.net";
_isConfigured = blobClient != null;
if (!_isConfigured)
{
_logger.LogWarning("[ImageStorage] Blob storage not configured - images will use source URLs");
}
else
{
_logger.LogInformation("[ImageStorage] Blob storage configured: {BaseUrl}/{Container}", _blobBaseUrl, _containerName);
}
}
///
/// Whether blob storage is configured and available.
///
public bool IsConfigured => _isConfigured;
///
/// Process a Creative draft response, downloading and storing images in blob storage.
/// Returns the modified JSON with blob URLs replacing source URLs.
///
public async Task ProcessCreativeDraftAsync(
string clientId,
string providerResponseJson,
CancellationToken ct)
{
if (!_isConfigured)
{
_logger.LogDebug("[ImageStorage] Skipping image processing - not configured");
return providerResponseJson;
}
try
{
using var doc = JsonDocument.Parse(providerResponseJson);
var root = doc.RootElement;
// Check if this is a successful response with data
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
return providerResponseJson;
if (!root.TryGetProperty("data", out var dataProp))
return providerResponseJson;
// Check if data has images array
if (!dataProp.TryGetProperty("images", out var imagesProp) ||
imagesProp.ValueKind != JsonValueKind.Array ||
imagesProp.GetArrayLength() == 0)
{
_logger.LogDebug("[ImageStorage] No images in draft response");
return providerResponseJson;
}
// Get draftId
var draftId = dataProp.TryGetProperty("draftId", out var draftIdProp)
? draftIdProp.GetString() ?? Guid.NewGuid().ToString("N")[..12]
: Guid.NewGuid().ToString("N")[..12];
_logger.LogInformation(
"[ImageStorage] Processing {Count} images for client {ClientId} draft {DraftId}",
imagesProp.GetArrayLength(), clientId, draftId);
// Ensure container exists
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: ct);
// Process each image and collect results
var processedImages = new List>();
foreach (var image in imagesProp.EnumerateArray())
{
var processedImage = await ProcessSingleImageAsync(
containerClient, clientId, draftId, image, ct);
processedImages.Add(processedImage);
}
// Rebuild the response with updated image URLs
return RebuildResponseWithProcessedImages(doc, processedImages);
}
catch (Exception ex)
{
_logger.LogError(ex, "[ImageStorage] Failed to process draft images, returning original response");
return providerResponseJson;
}
}
///
/// Process a single image: download and upload to blob storage.
///
private async Task> ProcessSingleImageAsync(
BlobContainerClient container,
string clientId,
string draftId,
JsonElement image,
CancellationToken ct)
{
// Extract image properties
var imageId = image.TryGetProperty("imageId", out var idProp) ? idProp.GetString() : null;
var sourceUrl = image.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null;
var downloadUrl = image.TryGetProperty("downloadUrl", out var dlProp) ? dlProp.GetString() : null;
var orientation = image.TryGetProperty("orientation", out var orProp) ? orProp.GetString() ?? "unknown" : "unknown";
var source = image.TryGetProperty("source", out var srcProp) ? srcProp.GetString() : "unknown";
var width = image.TryGetProperty("width", out var wProp) ? wProp.GetInt32() : 0;
var height = image.TryGetProperty("height", out var hProp) ? hProp.GetInt32() : 0;
var altText = image.TryGetProperty("altText", out var altProp) ? altProp.GetString() : null;
var attribution = image.TryGetProperty("attribution", out var attrProp) ? attrProp.GetString() : null;
// Build result with original properties
var result = new Dictionary
{
["imageId"] = imageId,
["url"] = sourceUrl, // Will be replaced with blob URL on success
["source"] = source,
["orientation"] = orientation,
["width"] = width,
["height"] = height,
["altText"] = altText,
["attribution"] = attribution,
["downloadUrl"] = downloadUrl,
["blobStored"] = false // Track whether we stored it
};
if (string.IsNullOrEmpty(sourceUrl))
{
_logger.LogWarning("[ImageStorage] Image has no URL, skipping");
return result;
}
try
{
// Download image bytes (prefer download URL for higher quality)
var fetchUrl = !string.IsNullOrEmpty(downloadUrl) ? downloadUrl : sourceUrl;
var httpClient = _httpFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(30);
_logger.LogDebug("[ImageStorage] Downloading from {Url}", fetchUrl);
using var response = await httpClient.GetAsync(fetchUrl, ct);
response.EnsureSuccessStatusCode();
var contentType = response.Content.Headers.ContentType?.MediaType ?? "image/jpeg";
var extension = GetExtensionFromContentType(contentType);
// Build blob path: {clientId}/drafts/{draftId}/{orientation}.{ext}
var blobName = $"{clientId}/drafts/{draftId}/{orientation}.{extension}";
var blobClient = container.GetBlobClient(blobName);
// Upload with proper content type and caching headers
await using var stream = await response.Content.ReadAsStreamAsync(ct);
var uploadOptions = new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders
{
ContentType = contentType,
CacheControl = "public, max-age=31536000" // 1 year cache
},
Metadata = new Dictionary
{
["source"] = source ?? "unknown",
["originalUrl"] = sourceUrl,
["orientation"] = orientation,
["width"] = width.ToString(),
["height"] = height.ToString(),
["clientId"] = clientId,
["draftId"] = draftId
}
};
await blobClient.UploadAsync(stream, uploadOptions, ct);
// Build permanent blob URL
var blobUrl = $"{_blobBaseUrl}/{_containerName}/{blobName}";
result["url"] = blobUrl;
result["blobStored"] = true;
result["originalUrl"] = sourceUrl; // Keep original for reference
_logger.LogInformation("[ImageStorage] Stored {Orientation} image: {BlobUrl}", orientation, blobUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[ImageStorage] Failed to store {Orientation} image, keeping original URL", orientation);
// Keep original URL as fallback
}
return result;
}
///
/// Rebuild the provider response JSON with processed image data.
///
private static string RebuildResponseWithProcessedImages(
JsonDocument original,
List> processedImages)
{
var root = original.RootElement;
// Build new response maintaining structure
var response = new Dictionary
{
["ok"] = root.GetProperty("ok").GetBoolean(),
["requestId"] = root.TryGetProperty("requestId", out var rid) ? rid.GetString() : null
};
// Rebuild data object with processed images
if (root.TryGetProperty("data", out var dataProp))
{
var data = new Dictionary();
// Copy all data properties except images
foreach (var prop in dataProp.EnumerateObject())
{
if (prop.Name == "images")
continue;
data[prop.Name] = JsonElementToObject(prop.Value);
}
// Add processed images
data["images"] = processedImages;
response["data"] = data;
}
// Copy error if present
if (root.TryGetProperty("error", out var errorProp))
{
response["error"] = JsonElementToObject(errorProp);
}
return JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
}
///
/// Convert JsonElement to object for serialization.
///
private static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
JsonValueKind.Array => element.EnumerateArray()
.Select(JsonElementToObject).ToList(),
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => null
};
}
///
/// Delete all images for a specific draft.
///
public async Task DeleteDraftImagesAsync(string clientId, string draftId, CancellationToken ct)
{
if (!_isConfigured) return;
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
var prefix = $"{clientId}/drafts/{draftId}/";
await foreach (var blob in containerClient.GetBlobsAsync(
traits: BlobTraits.None,
states: BlobStates.None,
prefix: prefix,
cancellationToken: ct))
{
await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct);
_logger.LogInformation("[ImageStorage] Deleted blob {Name}", blob.Name);
}
}
///
/// Delete all images for a client.
///
public async Task DeleteClientImagesAsync(string clientId, CancellationToken ct)
{
if (!_isConfigured) return;
var containerClient = _blobClient.GetBlobContainerClient(_containerName);
var prefix = $"{clientId}/";
var count = 0;
await foreach (var blob in containerClient.GetBlobsAsync(
traits: BlobTraits.None,
states: BlobStates.None,
prefix: prefix,
cancellationToken: ct))
{
await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct);
count++;
}
_logger.LogInformation("[ImageStorage] Deleted {Count} blobs for client {ClientId}", count, clientId);
}
private static string GetExtensionFromContentType(string contentType) => contentType switch
{
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"image/svg+xml" => "svg",
_ => "jpg"
};
}