using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class WorkOrderUpliftEvidenceService : IWorkOrderUpliftEvidenceService { private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) { "application/pdf", "image/jpeg", "image/jpg", "image/png" }; private readonly IWorkOrderDetailDataService _detailData; private readonly IDispatchDataService _dispatchData; private readonly IVendorDocumentDataService _documentData; private readonly IVendorDocumentStoragePort _documentStorage; private readonly IWorkOrderAccountResolver _accountResolver; private readonly VendorDocumentsOptions _documentOptions; public WorkOrderUpliftEvidenceService( IWorkOrderDetailDataService detailData, IDispatchDataService dispatchData, IVendorDocumentDataService documentData, IVendorDocumentStoragePort documentStorage, IWorkOrderAccountResolver accountResolver, IOptions documentOptions) { _detailData = detailData; _dispatchData = dispatchData; _documentData = documentData; _documentStorage = documentStorage; _accountResolver = accountResolver; _documentOptions = documentOptions.Value; } public async Task UploadAsync( int workOrderId, IFormFile file, ClaimsPrincipal user, CancellationToken cancellationToken) { var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken); if (target == null) return null; var (contentType, bytes) = await ReadAcceptedFileAsync(file, cancellationToken); var latest = await _documentData.GetLatestForDispatchAsync(target.Dispatch.Id, cancellationToken); var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{target.Dispatch.Id}_{version}_{Guid.NewGuid():N}{SafeExtension(file.FileName)}"; var now = DateTime.UtcNow; var passWithoutScanner = _documentOptions.PassWhenScannerUnavailable; var document = new VendorCompletionDocument { VendorId = target.Dispatch.VendorId, DispatchId = target.Dispatch.Id, WorkOrderId = workOrderId, OriginalFileName = Path.GetFileName(file.FileName), StoredFileName = storedFileName, ContentType = contentType, SizeBytes = bytes.Length, ScanStatus = passWithoutScanner ? "Passed" : "Pending", ReviewStatus = "Processing", ScannedAt = passWithoutScanner ? now : null, Version = version, Purpose = VendorDocumentPurpose.UpliftEvidence, CreatedDate = now }; await _documentData.AddAsync(document, cancellationToken); await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = workOrderId, DispatchId = target.Dispatch.Id, UserId = user.FindFirstValue(ClaimTypes.NameIdentifier), FieldName = $"Dispatch {target.Dispatch.DispatchNumber} Uplift Evidence", NewValue = document.OriginalFileName, Action = "uplift_evidence_uploaded", ActorType = "internal", CreatedAt = now }, cancellationToken); await _documentData.SaveChangesAsync(cancellationToken); using var stored = new MemoryStream(bytes); await _documentStorage.SaveAsync( target.Dispatch.VendorId, target.Dispatch.Id, storedFileName, stored, cancellationToken); return new UploadCompletionDocumentResultDTO { Id = document.Id, Version = document.Version, ScanStatus = document.ScanStatus, ReviewStatus = document.ReviewStatus, Purpose = document.Purpose }; } public async Task GetStatusAsync( int workOrderId, int documentId, ClaimsPrincipal user, CancellationToken cancellationToken) { var target = await ResolveOpenDispatchAsync(workOrderId, user, cancellationToken); if (target == null) return null; var document = await _documentData.GetMetadataForVendorDispatchAsync( documentId, target.Dispatch.Id, target.Dispatch.VendorId, cancellationToken); if (document == null || document.Purpose != VendorDocumentPurpose.UpliftEvidence) return null; return new VendorDocumentStatusDTO { Id = document.Id, OriginalFileName = document.OriginalFileName, ScanStatus = document.ScanStatus, ReviewStatus = document.ReviewStatus, Purpose = document.Purpose }; } private async Task ResolveOpenDispatchAsync( int workOrderId, ClaimsPrincipal user, CancellationToken cancellationToken) { var accountFilter = _accountResolver.ResolveAccountFilter(user); if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter)) return null; var workOrder = await _detailData.GetWorkOrderForMediaAsync( workOrderId, cancellationToken, accountFilter); if (workOrder?.PrimaryDispatchId is not int dispatchId) throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) { throw new InvalidOperationException( $"Cannot attach uplift evidence on a '{workOrder.LifecycleStatus}' work order"); } var dispatch = await _dispatchData.GetByIdAsync(dispatchId); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (dispatch.Status is "Verified" or "Cancelled" or "Canceled" or "Refused") throw new InvalidOperationException($"Cannot attach uplift evidence on a '{dispatch.Status}' dispatch"); return new OpenDispatch(dispatch); } private async Task<(string ContentType, byte[] Bytes)> ReadAcceptedFileAsync( IFormFile file, CancellationToken cancellationToken) { if (file is null || file.Length == 0) throw new InvalidOperationException("An evidence file is required."); if (file.Length > _documentOptions.MaxSizeBytes) throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); var contentType = (file.ContentType ?? string.Empty).Trim(); if (!AllowedContentTypes.Contains(contentType)) throw new InvalidOperationException("Only PDF, JPG, and PNG documents are accepted."); using var buffer = new MemoryStream(); await file.CopyToAsync(buffer, cancellationToken); var bytes = buffer.ToArray(); if (!MatchesSignature(contentType, bytes)) throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); return (contentType, bytes); } private static bool MatchesSignature(string contentType, byte[] bytes) { if (bytes.Length == 0) return false; if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 4 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; } if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; } if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) || contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; } return false; } private static string SafeExtension(string fileName) { var extension = Path.GetExtension(fileName); return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension; } private sealed record OpenDispatch(Dispatch Dispatch); } }