using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Application.Services;
///
/// One-time migration utility that copies files from the legacy local filesystem
/// (wwwroot/media/) to Azure Blob Storage containers, then optionally
/// deletes the originals.
///
/// The migration is idempotent: if a blob already exists in Azure
/// it is skipped (not overwritten). This means the operation is safe to run
/// multiple times — for example if a previous run was interrupted part-way through.
///
///
/// Container assignment is inferred from the relative path of each file
/// (see ). Files that do not match any known
/// path pattern are reported as failures but do not abort the overall run.
///
///
public class StorageMigrationService : IStorageMigrationService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger _logger;
///
/// Initialises the service with the blob storage provider, storage
/// configuration, and a logger for per-file progress messages.
///
public StorageMigrationService(
IAzureBlobStorageService blobService,
IOptions settings,
ILogger logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
///
/// Recursively enumerates all files under ,
/// determines the correct Azure Blob Storage container for each file, and
/// uploads those that do not already exist in Azure.
///
///
/// Absolute path to the root media directory on the local filesystem
/// (e.g. C:\app\wwwroot\media).
///
///
/// When true, the local file is deleted immediately after a successful
/// upload. Defaults to false so a dry-run can be performed first.
/// Warning: set to true only after verifying the
/// migration result — deleted local files cannot be recovered without a backup.
///
///
/// A summarising how many files were
/// migrated, skipped (already in Azure), and failed, along with total bytes
/// transferred and elapsed time.
///
public async Task MigrateFilesystemToAzureAsync(
string mediaBasePath,
bool deleteLocalAfterMigration = false)
{
var result = new StorageMigrationResult();
var stopwatch = Stopwatch.StartNew();
if (!Directory.Exists(mediaBasePath))
{
result.Errors.Add($"Media directory not found: {mediaBasePath}");
result.Duration = stopwatch.Elapsed;
return result;
}
var allFiles = Directory.EnumerateFiles(mediaBasePath, "*.*", SearchOption.AllDirectories).ToList();
_logger.LogInformation("Starting filesystem-to-Azure migration. Found {Count} files in {Path}", allFiles.Count, mediaBasePath);
foreach (var fullPath in allFiles)
{
var relativePath = Path.GetRelativePath(mediaBasePath, fullPath).Replace("\\", "/");
var container = DetermineContainer(relativePath);
if (container is null)
{
_logger.LogWarning("Could not determine container for file: {RelativePath} — skipping", relativePath);
result.Errors.Add($"Unknown file type (no matching container): {relativePath}");
result.Failed++;
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = "unknown",
FileSize = new FileInfo(fullPath).Length,
Status = MigrationFileStatus.Failed
});
continue;
}
try
{
var fileInfo = new FileInfo(fullPath);
// Skip if blob already exists in Azure
var alreadyExists = await _blobService.ExistsAsync(container, relativePath);
if (alreadyExists)
{
_logger.LogDebug("Blob already exists, skipping: {Container}/{BlobName}", container, relativePath);
result.Skipped++;
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = container,
FileSize = fileInfo.Length,
Status = MigrationFileStatus.Skipped
});
continue;
}
var contentType = GetContentType(Path.GetExtension(fullPath).ToLowerInvariant());
(bool Success, string ErrorMessage) uploadResult;
await using (var stream = File.OpenRead(fullPath))
{
uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
}
if (!uploadResult.Success)
{
result.Failed++;
result.Errors.Add($"Upload failed for {relativePath}: {uploadResult.ErrorMessage}");
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = container,
FileSize = fileInfo.Length,
Status = MigrationFileStatus.Failed
});
continue;
}
result.Migrated++;
result.BytesMigrated += fileInfo.Length;
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = container,
FileSize = fileInfo.Length,
Status = MigrationFileStatus.Migrated
});
if (deleteLocalAfterMigration)
{
File.Delete(fullPath);
_logger.LogInformation("Deleted local file after migration: {FullPath}", fullPath);
}
_logger.LogInformation("Migrated: {RelativePath} → {Container}", relativePath, container);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error migrating file: {RelativePath}", relativePath);
result.Failed++;
result.Errors.Add($"Exception for {relativePath}: {ex.Message}");
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = container,
FileSize = 0,
Status = MigrationFileStatus.Failed
});
}
}
stopwatch.Stop();
result.Duration = stopwatch.Elapsed;
_logger.LogInformation(
"Migration complete. Migrated: {Migrated}, Skipped: {Skipped}, Failed: {Failed}, Duration: {Duration}",
result.Migrated, result.Skipped, result.Failed, result.Duration);
return result;
}
///
/// Determines which Azure Blob Storage container a local file belongs in by
/// inspecting its path segments. The mapping mirrors the blob naming
/// conventions used by each file service:
///
/// - /profile-photos/ → profileimages container
/// - /job-photos/ → jobimages container
/// - /equipment-manuals/ → manuals container
/// - company-logo (anywhere in path) → companylogos container
///
/// Returns null for files that do not match any known pattern; the
/// caller treats these as failures and continues with the next file.
///
///
/// Path of the file relative to the media root, with forward slashes.
///
/// Container name, or null if no match is found.
private string? DetermineContainer(string relativePath)
{
if (relativePath.Contains("/profile-photos/")) return _settings.Containers.ProfileImages;
if (relativePath.Contains("/job-photos/")) return _settings.Containers.JobImages;
if (relativePath.Contains("/equipment-manuals/"))return _settings.Containers.Manuals;
if (relativePath.Contains("company-logo")) return _settings.Containers.CompanyLogos;
return null;
}
///
/// Maps a lowercase file extension to its canonical MIME content type for
/// the Content-Type metadata stored with each Azure blob. Correct
/// MIME types ensure browsers handle downloads appropriately (inline vs.
/// prompt for application) when blobs are accessed via signed URLs.
///
/// Lowercase file extension including the leading dot.
/// MIME type string, or application/octet-stream as a safe fallback.
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt" => "text/plain",
_ => "application/octet-stream"
};
}