using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Application.Services;
///
/// Manages the two-phase lifecycle of AI photo quote images in Azure Blob Storage.
/// Phase 1 (temp): photos uploaded during the quote wizard are stored under
/// temp/{tempId}/{guid}{ext} before the quote is saved and has an ID.
/// Phase 2 (permanent): when the quote is created or edited, temp blobs are promoted to
/// {companyId}/quote-photos/{quoteId}/{guid}{ext} and the temp blobs are deleted.
/// Keeping photos in temp storage until the quote is committed prevents orphaned blobs from
/// quotes that are abandoned mid-wizard, and ensures blobs are never written to a quote-scoped
/// path before a valid quote ID exists.
///
public class QuotePhotoService : IQuotePhotoService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger _logger;
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
///
/// Initializes the service with the blob storage wrapper, storage configuration, and logger.
///
public QuotePhotoService(
IAzureBlobStorageService blobService,
IOptions settings,
ILogger logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
// Temp blobs: temp/{tempId}/{guid}{ext}
// Permanent blobs: {companyId}/quote-photos/{quoteId}/{guid}{ext}
///
/// Validates and uploads a photo to temporary blob storage, generating a new tempId
/// (GUID) that the client uses to reference this photo during the rest of the quote wizard.
/// A fresh GUID blob name is used within the temp folder so that concurrent uploads for the
/// same session do not overwrite each other.
///
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
IFormFile file, int companyId)
{
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
if (!isValid)
return (false, string.Empty, string.Empty, validationError);
var tempId = Guid.NewGuid().ToString("N");
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
var contentType = BlobFileHelper.GetContentType(ext);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
if (!result.Success)
return (false, string.Empty, string.Empty, result.ErrorMessage);
_logger.LogInformation("Saved temp quote photo {TempId}: {BlobName}", tempId, blobName);
return (true, tempId, blobName, string.Empty);
}
///
/// Promotes a previously uploaded temp photo to its permanent blob path once the quote has been
/// saved and its ID is known. The photo is downloaded from temp storage and re-uploaded to the
/// permanent path rather than using a server-side copy API, which keeps the service compatible
/// with blob storage providers that do not support atomic renames. The temp blob is deleted
/// on a best-effort basis after a successful promotion.
///
public async Task<(bool Success, string FilePath, string ErrorMessage)> PromoteTempPhotoAsync(
string tempId, int quoteId, int companyId)
{
var prefix = $"temp/{tempId}/";
var blobs = (await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix)).ToList();
if (blobs.Count == 0)
return (false, string.Empty, "Temp photo not found.");
var srcBlob = blobs[0];
var ext = Path.GetExtension(srcBlob);
var destBlob = $"{companyId}/quote-photos/{quoteId}/{Guid.NewGuid():N}{ext}";
// Download from temp then re-upload to permanent location
var download = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, srcBlob);
if (!download.Success)
return (false, string.Empty, "Failed to read temp photo.");
using var ms = new MemoryStream(download.Content);
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, BlobFileHelper.GetContentType(ext));
if (!upload.Success)
return (false, string.Empty, "Failed to save permanent photo.");
// Best-effort cleanup of temp blob
await _blobService.DeleteAsync(_settings.Containers.QuoteImages, srcBlob);
_logger.LogInformation("Promoted temp photo {TempId} to {DestBlob}", tempId, destBlob);
return (true, destBlob, string.Empty);
}
///
/// Downloads a photo from blob storage and returns its raw bytes and content type.
/// Used by the Quotes/Photo controller action which streams photos stored outside
/// the public web root so they are not directly URL-accessible.
///
public async Task<(bool Success, byte[] Data, string ContentType)> ReadPhotoAsync(string filePath)
{
var result = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, filePath);
if (!result.Success)
return (false, Array.Empty(), string.Empty);
return (true, result.Content, result.ContentType);
}
///
/// Deletes a photo blob from storage. Returns true if deletion succeeded (including the
/// case where the blob does not exist, which the underlying service treats as a no-op success).
///
public async Task DeletePhotoAsync(string filePath)
{
var result = await _blobService.DeleteAsync(_settings.Containers.QuoteImages, filePath);
return result.Success;
}
///
/// Downloads all blobs under the temp prefix for a given and returns
/// them as in-memory byte arrays. Used by the AI analysis endpoint to supply photo bytes to the
/// Anthropic API when the quote has not yet been saved and photos have no permanent path.
///
public async Task> ReadTempPhotosAsync(string tempId)
{
var prefix = $"temp/{tempId}/";
var blobs = (await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix)).ToList();
var results = new List<(byte[] Data, string ContentType, string FileName)>();
foreach (var blobName in blobs)
{
var download = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, blobName);
if (download.Success)
results.Add((download.Content, download.ContentType, Path.GetFileName(blobName)));
}
return results;
}
///
/// Deletes all blobs under the temp prefix for a given .
/// Called when the user cancels the quote wizard or when promotion fails partway through so
/// that orphaned temp blobs do not accumulate in storage. Each blob deletion is wrapped in
/// its own try/catch so that one failed delete does not abort cleanup of the remaining blobs.
///
public async Task CleanupTempAsync(string tempId)
{
var prefix = $"temp/{tempId}/";
var blobs = await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix);
foreach (var blob in blobs)
{
try { await _blobService.DeleteAsync(_settings.Containers.QuoteImages, blob); }
catch (Exception ex) { _logger.LogWarning(ex, "Could not clean up temp blob {Blob}", blob); }
}
}
}