using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class WorkOrderMediaService : IWorkOrderMediaService { private readonly IWorkOrderMediaDataService _mediaData; private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAuditService _auditService; public WorkOrderMediaService( IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData, IWorkOrderAuditService auditService) { _mediaData = mediaData; _detailData = detailData; _auditService = auditService; } public async Task?> GetMediaAsync( int workOrderId, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { WorkOrderMediaAuthorization.EnsureCanRead(user, actorId); var accountFilter = ResolveAccountFilter(user); if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter)) return null; var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken, accountFilter); if (workOrder == null) return null; WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId!, workOrder); var attachments = await _detailData.GetAttachmentsAsync(workOrderId, cancellationToken); return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); } public async Task EnsureCanMutateMediaAsync( int workOrderId, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default, WorkOrderMediaCategory? category = null) { WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); // AsNoTracking pre-check so the subsequent AddMediaAsync load is not stale-cached. var workOrder = await GetMutableWorkOrderForAuthAsync(workOrderId, user, actorId!, cancellationToken); EnsureMediaMutationAllowed(workOrder.LifecycleStatus, category ?? WorkOrderMediaCategory.Extra); } public async Task AddMediaAsync( int workOrderId, WorkOrderMediaCategory? category, string fileUrl, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); EnsureMediaMutationAllowed(workOrder.LifecycleStatus, resolvedCategory); if (resolvedCategory == WorkOrderMediaCategory.Completion) { throw new WorkOrderBoardValidationException( "UseCompletionDocEndpoint", "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); } if (resolvedCategory == WorkOrderMediaCategory.Before) { var oldBefore = workOrder.BeforPhotoAttachment; workOrder.BeforPhotoAttachment = fileUrl; await _auditService.StageFieldChangedAsync( workOrderId, "BeforPhotoAttachment", oldBefore, fileUrl, actorId); await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = -1, Category = resolvedCategory, Url = fileUrl, IsLegacy = true }; } if (resolvedCategory == WorkOrderMediaCategory.After) { var oldAfter = workOrder.AfterPhotoAttachment; workOrder.AfterPhotoAttachment = fileUrl; await _auditService.StageFieldChangedAsync( workOrderId, "AfterPhotoAttachment", oldAfter, fileUrl, actorId); await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = -2, Category = resolvedCategory, Url = fileUrl, IsLegacy = true }; } var attachment = new WorkOrderAttachments { WorkorderId = workOrderId, Attachments = fileUrl, Category = category.HasValue ? resolvedCategory : null, CreatedDate = DateTime.UtcNow, createdby = actorId }; _mediaData.TrackAttachment(attachment); await _auditService.StageFieldChangedAsync( workOrderId, "MediaCategory", null, FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()), actorId); await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = attachment.Id, Category = attachment.Category ?? WorkOrderMediaCategory.Extra, Url = fileUrl, UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), IsLegacy = false }; } public async Task UpdateMediaCategoryAsync( int workOrderId, int mediaId, WorkOrderMediaCategory category, string? workOrderVersion, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint."); if (category == WorkOrderMediaCategory.Completion) { throw new WorkOrderBoardValidationException( "UseCompletionDocEndpoint", "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); } var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); ApplyExpectedVersion(workOrder, workOrderVersion); var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken); if (attachment == null) throw new WorkOrderBoardValidationException("NotFound", "Media not found."); var currentCategory = attachment.Category ?? WorkOrderMediaCategory.Extra; EnsureMediaMutationAllowed(workOrder.LifecycleStatus, currentCategory); EnsureMediaMutationAllowed(workOrder.LifecycleStatus, category); var priorCategory = currentCategory.ToString(); if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After) { var url = attachment.Attachments ?? ""; if (category == WorkOrderMediaCategory.Before) workOrder.BeforPhotoAttachment = url; else workOrder.AfterPhotoAttachment = url; attachment.IsDeleted = true; attachment.DeletionTime = DateTime.UtcNow; attachment.DeleterUserId = actorId; await _auditService.StageFieldChangedAsync( workOrderId, "MediaCategory", FormatMediaAuditValue(mediaId, priorCategory), FormatMediaAuditValue(mediaId, category.ToString()), actorId); await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = category == WorkOrderMediaCategory.Before ? -1 : -2, Category = category, Url = url, IsLegacy = true }; } attachment.Category = category; _mediaData.MarkWorkOrderModified(workOrder); await _auditService.StageFieldChangedAsync( workOrderId, "MediaCategory", FormatMediaAuditValue(mediaId, priorCategory), FormatMediaAuditValue(mediaId, category.ToString()), actorId); await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = attachment.Id, Category = attachment.Category ?? WorkOrderMediaCategory.Extra, Url = attachment.Attachments ?? "", UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), IsLegacy = false }; } public async Task DeleteMediaAsync( int workOrderId, int mediaId, string? workOrderVersion, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { WorkOrderMediaAuthorization.EnsureCanDelete(user, actorId); if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint."); var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); ApplyExpectedVersion(workOrder, workOrderVersion); var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken); if (attachment == null) throw new WorkOrderBoardValidationException("NotFound", "Media not found."); var currentCategory = attachment.Category ?? WorkOrderMediaCategory.Extra; EnsureMediaMutationAllowed(workOrder.LifecycleStatus, currentCategory); var priorCategory = currentCategory.ToString(); attachment.IsDeleted = true; attachment.DeletionTime = DateTime.UtcNow; attachment.DeleterUserId = actorId; _mediaData.MarkWorkOrderModified(workOrder); await _auditService.StageFieldChangedAsync( workOrderId, "MediaCategory", FormatMediaAuditValue(mediaId, priorCategory), FormatMediaAuditValue(mediaId, "Deleted"), actorId); await SaveMediaAsync(cancellationToken); } private async Task GetMutableWorkOrderForAuthAsync( int workOrderId, ClaimsPrincipal user, string actorId, CancellationToken cancellationToken) { var accountFilter = ResolveAccountFilter(user); var workOrder = await _mediaData.GetWorkOrderForMediaAuthAsync(workOrderId, accountFilter, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder); return workOrder; } private async Task GetMutableWorkOrderAsync( int workOrderId, ClaimsPrincipal user, string actorId, CancellationToken cancellationToken) { var accountFilter = ResolveAccountFilter(user); // Fresh tracked load (not the AsNoTracking pre-check entity). var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, accountFilter, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder); return workOrder; } private static void EnsureMediaMutationAllowed( LifecycleStatus? status, WorkOrderMediaCategory category) { if (!WorkOrderBoardMutationRules.CanMutateExtraMedia(status, category)) throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); } /// /// Account filter for data queries. Null means org-wide (skip ApplyAccountScope). /// Call only after EnsureCan* has verified scope is not Missing. /// private static int? ResolveAccountFilter(ClaimsPrincipal user) { return WorkOrderMediaAuthorization.ResolveMediaScope(user) switch { MediaAccountScope.Account account => account.AccountId, MediaAccountScope.OrgWide => null, _ => throw new WorkOrderBoardValidationException( "Forbidden", "You are not allowed to access work order media without account scope.") }; } private async Task SaveMediaAsync(CancellationToken cancellationToken) { try { await _mediaData.SaveAsync(cancellationToken); } catch (DbUpdateConcurrencyException) { throw new WorkOrderBoardValidationException( "ConcurrencyConflict", "Work order was modified. Refresh and retry."); } } private void ApplyExpectedVersion(WorkOrder workOrder, string? workOrderVersion) { var expected = ParseRowVersion(workOrderVersion); if (expected == null) throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(expected)) throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry."); _mediaData.SetExpectedWorkOrderVersion(workOrder, expected); } private static byte[]? ParseRowVersion(string? base64) { if (string.IsNullOrWhiteSpace(base64)) return null; try { return Convert.FromBase64String(base64); } catch (FormatException) { throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format."); } } private static string FormatMediaAuditValue(int? mediaId, string category) => mediaId.HasValue ? $"{mediaId.Value}:{category}" : category; } }