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 user profile photos stored in Azure Blob Storage.
/// Photos are stored in the profileimages container under the
/// deterministic path {companyId}/profile-photos/{userId}{ext}
/// (e.g. 7/profile-photos/abc-guid.png).
///
/// A deterministic path (keyed by userId rather than a random GUID) is used
/// here intentionally — unlike job photos, profile photos are accessed by
/// user ID from multiple places (nav bar, worker cards) and a predictable
/// path avoids the need to store it in an additional DB column. Access is
/// still protected: the /Profile/Photo endpoint validates that the
/// requesting user is authenticated before streaming the image.
///
///
public class ProfilePhotoService : IProfilePhotoService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger _logger;
/// Image extensions accepted for profile photos.
private static readonly string[] AllowedImageTypes = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
/// Maximum profile photo size accepted on upload (10 MB).
private const long MaxPhotoSize = 10 * 1024 * 1024; // 10 MB
///
/// Initialises the service with the blob storage provider, storage
/// configuration, and a logger for upload audit messages.
///
public ProfilePhotoService(
IAzureBlobStorageService blobService,
IOptions settings,
ILogger logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
///
/// Validates and uploads a user profile photo, replacing any existing photo
/// regardless of its extension. Old blobs at alternative extensions are
/// deleted first (via ) so a user
/// never has multiple active photos in the container.
/// The blob path mirrors the former filesystem path to simplify the migration
/// from local storage to Azure without requiring a database schema change.
///
/// The uploaded image file from the HTTP request.
/// The ASP.NET Identity user ID (GUID string).
/// The tenant company's database ID (for path scoping).
///
/// A tuple with a success flag, the stored blob path (on success), and a
/// human-readable error message (on failure).
///
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveProfilePhotoAsync(
IFormFile file,
string userId,
int companyId)
{
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
if (!isValid)
return (false, string.Empty, error);
// Delete old photos for this user with different extensions
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
// Blob path mirrors former filesystem path
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
if (!result.Success)
return (false, string.Empty, result.ErrorMessage);
_logger.LogInformation("Profile photo saved: {BlobName} for user {UserId}", blobName, userId);
return (true, blobName, string.Empty);
}
///
/// Deletes the profile photo blob at the given path from Azure Blob Storage.
/// Called when a user removes their avatar from the Profile settings page.
///
/// Blob-relative path previously returned by .
/// Success flag and an error message on failure.
public async Task<(bool Success, string ErrorMessage)> DeleteProfilePhotoAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return (false, "File path is required.");
return await _blobService.DeleteAsync(_settings.Containers.ProfileImages, filePath);
}
///
/// Downloads the raw bytes of a profile photo for serving through the
/// /Profile/Photo controller endpoint, which enforces authentication
/// before streaming the image to the client.
///
/// Blob-relative path of the photo.
///
/// A tuple with a success flag, the raw file bytes, the MIME content type,
/// and an error message on failure.
///
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetProfilePhotoAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return (false, Array.Empty(), string.Empty, "File path is required.");
return await _blobService.DownloadAsync(_settings.Containers.ProfileImages, filePath);
}
///
/// Checks whether a profile photo blob exists without downloading its content.
/// Used by the nav bar and worker card views to decide whether to show the
/// user's avatar or a generic placeholder icon.
///
/// Blob-relative path to check.
/// true if the blob exists; otherwise false.
public async Task ProfilePhotoExistsAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return false;
return await _blobService.ExistsAsync(_settings.Containers.ProfileImages, filePath);
}
///
/// Attempts to delete any pre-existing profile photo blobs for the user at
/// extensions other than so that uploading
/// a new PNG replaces an old JPG (and vice versa).
/// Errors are caught and logged as warnings rather than propagated because a
/// missing old blob is not a blocking failure — the new photo should still be
/// saved successfully.
///
/// The tenant company's database ID.
/// The ASP.NET Identity user ID.
///
/// Extension of the incoming upload; blobs with this extension are skipped
/// because they will be overwritten immediately afterwards.
///
private async Task DeleteOldPhotosForUserAsync(int companyId, string userId, string currentExtension)
{
try
{
foreach (var ext in AllowedImageTypes)
{
if (ext == currentExtension) continue;
var oldBlobName = $"{companyId}/profile-photos/{userId}{ext}";
await _blobService.DeleteAsync(_settings.Containers.ProfileImages, oldBlobName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error deleting old profile photos for user {UserId}", userId);
}
}
}