//
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

#import "TSAttachmentStream.h"
#import "OWSError.h"
#import "OWSFileSystem.h"
#import "TSAttachmentPointer.h"
#import <AVFoundation/AVFoundation.h>
#import <SignalCoreKit/Threading.h>
#import <SignalServiceKit/SignalServiceKit-Swift.h>
#import <YYImage/YYImage.h>

NS_ASSUME_NONNULL_BEGIN

typedef void (^OWSLoadedThumbnailSuccess)(OWSLoadedThumbnail *loadedThumbnail);

NSString *NSStringForAttachmentThumbnailQuality(TSAttachmentThumbnailQuality value)
{
    switch (value) {
        case TSAttachmentThumbnailQuality_Small:
            return @"Small";
        case TSAttachmentThumbnailQuality_Medium:
            return @"Medium";
        case TSAttachmentThumbnailQuality_MediumLarge:
            return @"MediumLarge";
        case TSAttachmentThumbnailQuality_Large:
            return @"Large";
    }
}

@interface TSAttachmentStream ()

// We only want to generate the file path for this attachment once, so that
// changes in the file path generation logic don't break existing attachments.
@property (nullable, nonatomic) NSString *localRelativeFilePath;

// These properties should only be accessed while synchronized on self.
//
// In pixels, not points.
@property (nullable, nonatomic) NSNumber *cachedImageWidth;
@property (nullable, nonatomic) NSNumber *cachedImageHeight;

// This property should only be accessed on the main thread.
@property (nullable, nonatomic) NSNumber *cachedAudioDurationSeconds;

@property (atomic, nullable) NSNumber *isValidImageCached;
@property (atomic, nullable) NSNumber *isValidVideoCached;
@property (atomic, nullable) NSNumber *isAnimatedCached;

@end

#pragma mark -

@implementation TSAttachmentStream

- (instancetype)initWithContentType:(NSString *)contentType
                          byteCount:(UInt32)byteCount
                     sourceFilename:(nullable NSString *)sourceFilename
                            caption:(nullable NSString *)caption
                     attachmentType:(TSAttachmentType)attachmentType
                     albumMessageId:(nullable NSString *)albumMessageId
{
    self = [super initAttachmentWithContentType:contentType
                                      byteCount:byteCount
                                 sourceFilename:sourceFilename
                                        caption:caption
                                 attachmentType:attachmentType
                                 albumMessageId:albumMessageId];
    if (!self) {
        return self;
    }

    // TSAttachmentStream doesn't have any "incoming vs. outgoing"
    // state, but this constructor is used only for new outgoing
    // attachments which haven't been uploaded yet.
    _isUploaded = NO;
    _creationTimestamp = [NSDate new];

    [self ensureFilePath];

    return self;
}

- (instancetype)initWithPointer:(TSAttachmentPointer *)pointer transaction:(SDSAnyReadTransaction *)transaction
{
    // Once saved, this AttachmentStream will replace the AttachmentPointer in the attachments collection.
    self = [super initWithPointer:pointer transaction:transaction];
    if (!self) {
        return self;
    }

    OWSAssertDebug([NSObject isNullableObject:self.contentType equalTo:pointer.contentType]);

    // TSAttachmentStream doesn't have any "incoming vs. outgoing"
    // state, but this constructor is used only for new incoming
    // attachments which don't need to be uploaded.
    _isUploaded = YES;
    self.digest = pointer.digest;
    _creationTimestamp = [NSDate new];

    [self ensureFilePath];

    return self;
}

- (nullable instancetype)initWithCoder:(NSCoder *)coder
{
    self = [super initWithCoder:coder];
    if (!self) {
        return self;
    }

    // OWS105AttachmentFilePaths will ensure the file path is saved if necessary.
    [self ensureFilePath];

    // OWS105AttachmentFilePaths will ensure the creation timestamp is saved if necessary.
    if (!_creationTimestamp) {
        _creationTimestamp = [NSDate new];
    }

    return self;
}

// --- CODE GENERATION MARKER

// This snippet is generated by /Scripts/sds_codegen/sds_generate.py. Do not manually edit it, instead run
// `sds_codegen.sh`.

// clang-format off

- (instancetype)initWithGrdbId:(int64_t)grdbId
                      uniqueId:(NSString *)uniqueId
                  albumMessageId:(nullable NSString *)albumMessageId
         attachmentSchemaVersion:(NSUInteger)attachmentSchemaVersion
                  attachmentType:(TSAttachmentType)attachmentType
                        blurHash:(nullable NSString *)blurHash
                       byteCount:(unsigned int)byteCount
                         caption:(nullable NSString *)caption
                          cdnKey:(NSString *)cdnKey
                       cdnNumber:(unsigned int)cdnNumber
                      clientUuid:(nullable NSString *)clientUuid
                     contentType:(NSString *)contentType
                   encryptionKey:(nullable NSData *)encryptionKey
                        serverId:(unsigned long long)serverId
                  sourceFilename:(nullable NSString *)sourceFilename
                 uploadTimestamp:(unsigned long long)uploadTimestamp
                   videoDuration:(nullable NSNumber *)videoDuration
      cachedAudioDurationSeconds:(nullable NSNumber *)cachedAudioDurationSeconds
               cachedImageHeight:(nullable NSNumber *)cachedImageHeight
                cachedImageWidth:(nullable NSNumber *)cachedImageWidth
               creationTimestamp:(NSDate *)creationTimestamp
                          digest:(nullable NSData *)digest
                isAnimatedCached:(nullable NSNumber *)isAnimatedCached
                      isUploaded:(BOOL)isUploaded
              isValidImageCached:(nullable NSNumber *)isValidImageCached
              isValidVideoCached:(nullable NSNumber *)isValidVideoCached
           localRelativeFilePath:(nullable NSString *)localRelativeFilePath
{
    self = [super initWithGrdbId:grdbId
                        uniqueId:uniqueId
                    albumMessageId:albumMessageId
           attachmentSchemaVersion:attachmentSchemaVersion
                    attachmentType:attachmentType
                          blurHash:blurHash
                         byteCount:byteCount
                           caption:caption
                            cdnKey:cdnKey
                         cdnNumber:cdnNumber
                        clientUuid:clientUuid
                       contentType:contentType
                     encryptionKey:encryptionKey
                          serverId:serverId
                    sourceFilename:sourceFilename
                   uploadTimestamp:uploadTimestamp
                     videoDuration:videoDuration];

    if (!self) {
        return self;
    }

    _cachedAudioDurationSeconds = cachedAudioDurationSeconds;
    _cachedImageHeight = cachedImageHeight;
    _cachedImageWidth = cachedImageWidth;
    _creationTimestamp = creationTimestamp;
    _digest = digest;
    _isAnimatedCached = isAnimatedCached;
    _isUploaded = isUploaded;
    _isValidImageCached = isValidImageCached;
    _isValidVideoCached = isValidVideoCached;
    _localRelativeFilePath = localRelativeFilePath;

    [self sdsFinalizeAttachmentStream];

    return self;
}

// clang-format on

// --- CODE GENERATION MARKER

- (void)sdsFinalizeAttachmentStream
{
    [self upgradeAttachmentSchemaVersionIfNecessary];
}

- (void)upgradeFromAttachmentSchemaVersion:(NSUInteger)attachmentSchemaVersion
{
    [super upgradeFromAttachmentSchemaVersion:attachmentSchemaVersion];

    if (attachmentSchemaVersion < 1) {
        // Older video attachments could incorrectly be marked as not
        // valid before we increased our size limits to allow 4k video.
        if (self.isValidVideoCached && !self.isValidVideoCached.boolValue) {
            self.isValidVideoCached = nil;
        }
    }
}

- (void)ensureFilePath
{
    if (self.localRelativeFilePath) {
        return;
    }

    NSString *attachmentsFolder = [[self class] attachmentsFolder];
    NSString *filePath = [[self class] filePathForAttachment:self.uniqueId
                                                    mimeType:self.contentType
                                              sourceFilename:self.sourceFilename
                                                      folder:attachmentsFolder];
    if (!filePath) {
        OWSFailDebug(@"Could not generate path for attachment.");
        return;
    }
    if (![filePath hasPrefix:attachmentsFolder]) {
        OWSFailDebug(@"Attachment paths should all be in the attachments folder.");
        return;
    }
    NSString *localRelativeFilePath = [filePath substringFromIndex:attachmentsFolder.length];
    if (localRelativeFilePath.length < 1) {
        OWSFailDebug(@"Empty local relative attachment paths.");
        return;
    }

    self.localRelativeFilePath = localRelativeFilePath;
    OWSAssertDebug(self.originalFilePath);
}

#pragma mark - File Management

- (nullable NSData *)readDataFromFileWithError:(NSError **)error
{
    *error = nil;
    NSString *_Nullable filePath = self.originalFilePath;
    if (!filePath) {
        OWSFailDebug(@"Missing path for attachment.");
        return nil;
    }
    return [NSData dataWithContentsOfFile:filePath options:0 error:error];
}

- (BOOL)writeData:(NSData *)data error:(NSError **)error
{
    OWSAssertDebug(data);

    *error = nil;
    NSString *_Nullable filePath = self.originalFilePath;
    if (!filePath) {
        *error = OWSErrorMakeAssertionError(@"Missing path for attachment.");
        return NO;
    }
    return [data writeToFile:filePath options:0 error:error];
}

- (BOOL)writeCopyingDataSource:(id<DataSource>)dataSource error:(NSError **)error
{
    OWSAssertDebug(dataSource);

    NSURL *_Nullable originalMediaURL = self.originalMediaURL;
    if (originalMediaURL == nil) {
        *error = OWSErrorMakeAssertionError(@"Missing URL for attachment.");
        return NO;
    }
    return [dataSource writeToUrl:originalMediaURL error:error];
}

- (BOOL)writeConsumingDataSource:(id<DataSource>)dataSource error:(NSError **)error
{
    OWSAssertDebug(dataSource);

    NSURL *_Nullable originalMediaURL = self.originalMediaURL;
    if (originalMediaURL == nil) {
        *error = OWSErrorMakeAssertionError(@"Missing URL for attachment.");
        return NO;
    }
    return [dataSource moveToUrlAndConsume:originalMediaURL error:error];
}

+ (NSString *)legacyAttachmentsDirPath
{
    return [[OWSFileSystem appDocumentDirectoryPath] stringByAppendingPathComponent:@"Attachments"];
}

+ (NSString *)sharedDataAttachmentsDirPath
{
    return [[OWSFileSystem appSharedDataDirectoryPath] stringByAppendingPathComponent:@"Attachments"];
}

+ (NSString *)attachmentsFolder
{
    static NSString *attachmentsFolder = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        attachmentsFolder = TSAttachmentStream.sharedDataAttachmentsDirPath;

        [OWSFileSystem ensureDirectoryExists:attachmentsFolder];
    });
    return attachmentsFolder;
}

- (nullable NSString *)originalFilePath
{
    if (!self.localRelativeFilePath) {
        OWSFailDebug(@"Attachment missing local file path.");
        return nil;
    }

    return [[[self class] attachmentsFolder] stringByAppendingPathComponent:self.localRelativeFilePath];
}

/// For new attachments, we create a folder based on the uniqueId where we store all attachment data.
/// Legacy attachments may have data stored directly in the `attachmentsFolder`.
- (nullable NSString *)uniqueIdAttachmentFolder
{
    return [[[self class] attachmentsFolder] stringByAppendingPathComponent:self.uniqueId];
}

- (BOOL)ensureUniqueIdAttachmentFolder
{
    return [OWSFileSystem ensureDirectoryExists:self.uniqueIdAttachmentFolder];
}

- (nullable NSString *)audioWaveformPath
{
    if (!self.isAudioMimeType) {
        return nil;
    }

    return [self.uniqueIdAttachmentFolder stringByAppendingPathComponent:@"waveform.dat"];
}

- (nullable NSString *)legacyThumbnailPath
{
    NSString *filePath = self.originalFilePath;
    if (!filePath) {
        OWSFailDebug(@"Attachment missing local file path.");
        return nil;
    }

    if (!self.isImageMimeType && !self.isVideoMimeType && [self getAnimatedMimeType] == TSAnimatedMimeTypeNotAnimated) {
        return nil;
    }

    NSString *filename = filePath.lastPathComponent.stringByDeletingPathExtension;
    NSString *containingDir = filePath.stringByDeletingLastPathComponent;
    NSString *newFilename = [filename stringByAppendingString:@"-signal-ios-thumbnail"];

    return [[containingDir stringByAppendingPathComponent:newFilename] stringByAppendingPathExtension:@"jpg"];
}

- (NSString *)thumbnailsDirPath
{
    if (!self.localRelativeFilePath) {
        OWSFailDebug(@"Attachment missing local file path.");
        return nil;
    }

    // Thumbnails are written to the caches directory, so that iOS can
    // remove them if necessary.
    NSString *dirName = [NSString stringWithFormat:@"%@-thumbnails", self.uniqueId];
    return [OWSFileSystem.cachesDirectoryPath stringByAppendingPathComponent:dirName];
}

- (NSString *)pathForThumbnailDimensionPoints:(CGFloat)thumbnailDimensionPoints
{
    NSString *fileExtension = [OWSThumbnailService thumbnailFileExtensionForContentType:self.contentType];
    NSString *filename =
        [NSString stringWithFormat:@"thumbnail-%lu.%@", (unsigned long)thumbnailDimensionPoints, fileExtension];
    return [self.thumbnailsDirPath stringByAppendingPathComponent:filename];
}

- (nullable NSURL *)originalMediaURL
{
    NSString *_Nullable filePath = self.originalFilePath;
    if (!filePath) {
        OWSFailDebug(@"Missing path for attachment.");
        return nil;
    }
    return [NSURL fileURLWithPath:filePath];
}

- (void)removeFile
{
    NSString *_Nullable thumbnailsDirPath = self.thumbnailsDirPath;
    if (thumbnailsDirPath && ![OWSFileSystem deleteFileIfExists:thumbnailsDirPath]) {
        OWSLogError(@"remove thumbnails dir failed.");
    }

    NSString *_Nullable legacyThumbnailPath = self.legacyThumbnailPath;
    if (legacyThumbnailPath && ![OWSFileSystem deleteFileIfExists:legacyThumbnailPath]) {
        OWSLogError(@"remove legacy thumbnail failed.");
    }

    NSString *_Nullable filePath = self.originalFilePath;
    OWSAssertDebug(filePath);
    if (filePath && ![OWSFileSystem deleteFileIfExists:filePath]) {
        OWSLogError(@"remove file failed");
    }

    // Remove the attachment specific directory and any associated files stored for this attachment.
    NSString *_Nullable attachmentFolder = self.uniqueIdAttachmentFolder;
    if (attachmentFolder && ![OWSFileSystem deleteFileIfExists:attachmentFolder]) {
        OWSFailDebug(@"remove unique attachment folder failed.");
    }
}

- (void)anyDidInsertWithTransaction:(SDSAnyWriteTransaction *)transaction
{
    [super anyDidInsertWithTransaction:transaction];
    [self anyDidInsertSwiftWithTx:transaction];
}

- (void)anyDidRemoveWithTransaction:(SDSAnyWriteTransaction *)transaction
{
    [super anyDidRemoveWithTransaction:transaction];

    [self removeFile];
    [self anyDidRemoveSwiftWithTx:transaction];
}

- (BOOL)isValidVisualMedia
{
    return [self isValidVisualMediaIgnoringSize:NO];
}

- (BOOL)isValidVisualMediaIgnoringSize:(BOOL)ignoreSize
{
    if (self.isImageMimeType && [self isValidImageIgnoringSize:ignoreSize]) {
        return YES;
    }

    if (self.isVideoMimeType && [self isValidVideoIgnoringSize:ignoreSize]) {
        return YES;
    }

    if ([self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated && [self isValidImageIgnoringSize:ignoreSize]) {
        return YES;
    }

    return NO;
}

#pragma mark - Image Validation

- (BOOL)isValidImage
{
    return [self isValidImageIgnoringSize:NO];
}

- (BOOL)isValidImageIgnoringSize:(BOOL)ignoreSize
{
    OWSAssertDebug(self.isImageMimeType || [self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated);

    BOOL result;
    BOOL didUpdateCache = NO;
    @synchronized(self) {
        if (!self.isValidImageCached) {
            self.isValidImageCached = @([NSData imageMetadataWithPath:self.originalFilePath
                                                             mimeType:self.contentType
                                                       ignoreFileSize:ignoreSize]
                                            .isValid);
            if (!self.isValidImageCached.boolValue) {
                OWSLogWarn(@"Invalid image.");
            }
            didUpdateCache = YES;
        }
        result = self.isValidImageCached.boolValue;
    }

    if (didUpdateCache && self.canAsyncUpdate) {
        [self applyChangeAsyncToLatestCopyWithChangeBlock:^(
            TSAttachmentStream *latestInstance) { latestInstance.isValidImageCached = @(result); }];
    }

    return result;
}

- (BOOL)canAsyncUpdate
{
    return !AppContextObjcBridge.CurrentAppContext.isRunningTests;
}

- (BOOL)isValidVideo
{
    return [self isValidVideoIgnoringSize:NO];
}

- (BOOL)isValidVideoIgnoringSize:(BOOL)ignoreSize
{
    OWSAssertDebug(self.isVideoMimeType);

    BOOL result;
    BOOL didUpdateCache = NO;
    @synchronized(self) {
        if (!self.isValidVideoCached) {
            self.isValidVideoCached = @([OWSMediaUtils isValidVideoWithPath:self.originalFilePath
                                                                 ignoreSize:ignoreSize]);
            if (!self.isValidVideoCached) {
                OWSLogWarn(@"Invalid video.");
            }
            didUpdateCache = YES;
        }
        result = self.isValidVideoCached.boolValue;
    }

    if (didUpdateCache && self.canAsyncUpdate) {
        [self applyChangeAsyncToLatestCopyWithChangeBlock:^(
            TSAttachmentStream *latestInstance) { latestInstance.isValidVideoCached = @(result); }];
    }

    return result;
}

- (BOOL)isAnimatedContent
{
    BOOL result;
    BOOL didUpdateCache = NO;
    @synchronized(self) {
        if (!self.isAnimatedCached) {
            self.isAnimatedCached = @([self hasAnimatedImageContent]);
            didUpdateCache = YES;
        }
        result = self.isAnimatedCached.boolValue;
    }

    if (didUpdateCache && self.canAsyncUpdate) {
        [self applyChangeAsyncToLatestCopyWithChangeBlock:^(
            TSAttachmentStream *latestInstance) { latestInstance.isAnimatedCached = @(result); }];
    }

    return result;
}

- (BOOL)shouldBeRenderedByYY
{
    if ([MimeTypeUtil isSupportedDefinitelyAnimatedMimeType:self.contentType]) {
        return YES;
    }
    return self.isAnimatedContent;
}

- (BOOL)hasAnimatedImageContent
{
    return [OWSVideoAttachmentDetection.sharedInstance attachmentStreamIsAnimated:self];
}

#pragma mark -

- (nullable UIImage *)originalImage
{
    if ([self isVideoMimeType]) {
        return [self videoStillImage];
    } else if ([self isImageMimeType] || [self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated) {
        NSString *_Nullable originalFilePath = self.originalFilePath;
        if (!originalFilePath) {
            return nil;
        }
        if (![self isValidImage]) {
            return nil;
        }
        UIImage *_Nullable image;
        if (self.isWebpImageMimeType) {
            image = [[YYImage alloc] initWithContentsOfFile:originalFilePath];
        } else {
            image = [[UIImage alloc] initWithContentsOfFile:originalFilePath];
        }
        if (image == nil) {
            OWSFailDebug(
                @"Couldn't load original image: %d.", [OWSFileSystem fileOrFolderExistsAtPath:originalFilePath]);
        }
        return image;
    } else {
        return nil;
    }
}

- (nullable NSData *)validStillImageData
{
    if ([self isVideoMimeType]) {
        OWSFailDebug(@"isVideo was unexpectedly true");
        return nil;
    }
    if ([self getAnimatedMimeType] == TSAnimatedMimeTypeAnimated) {
        OWSFailDebug(@"isAnimated was unexpectedly true");
        return nil;
    }

    if (![NSData ows_isValidImageAtPath:self.originalFilePath mimeType:self.contentType]) {
        OWSFailDebug(@"skipping invalid image");
        return nil;
    }

    return [NSData dataWithContentsOfFile:self.originalFilePath];
}

- (nullable UIImage *)videoStillImage
{
    NSError *error;
    UIImage *_Nullable image = [OWSMediaUtils thumbnailForVideoAtPath:self.originalFilePath
                                                   maxDimensionPoints:[TSAttachmentStream thumbnailDimensionPointsLarge]
                                                                error:&error];
    if (error || !image) {
        OWSLogError(@"Could not create video still: %@.", error);
        return nil;
    }
    return image;
}

+ (void)deleteAttachmentsFromDisk
{
    NSError *error;
    NSFileManager *fileManager = [NSFileManager defaultManager];

    NSURL *fileURL = [NSURL fileURLWithPath:self.attachmentsFolder];
    NSArray<NSURL *> *contents = [fileManager contentsOfDirectoryAtURL:fileURL
                                            includingPropertiesForKeys:nil
                                                               options:0
                                                                 error:&error];

    if (error) {
        OWSFailDebug(@"failed to get contents of attachments folder: %@ with error: %@", self.attachmentsFolder, error);
        return;
    }

    for (NSURL *url in contents) {
        [fileManager removeItemAtURL:url error:&error];
        if (error) {
            OWSFailDebug(@"failed to remove item at path: %@ with error: %@", url, error);
        }
    }
}

- (CGSize)calculateImageSizePixels
{
    if ([self isVideoMimeType]) {
        if (![self isValidVideo]) {
            return CGSizeZero;
        }
        return [[self videoStillImage] pixelSize];
    } else if ([self isImageMimeType] || [self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated) {
        // imageSizeForFilePath checks validity.
        return [NSData imageSizeForFilePath:self.originalFilePath mimeType:self.contentType];
    } else {
        return CGSizeZero;
    }
}

- (BOOL)shouldHaveImageSize
{
    return ([self isVideoMimeType] || [self isImageMimeType] ||
        [self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated);
}

- (CGSize)imageSizePixels
{
    if (!self.shouldHaveImageSize) {
        OWSFailDebug(@"Content type does not have image sync.");
        return CGSizeZero;
    }

    @synchronized(self) {
        if (self.cachedImageWidth && self.cachedImageHeight) {
            return CGSizeMake(self.cachedImageWidth.floatValue, self.cachedImageHeight.floatValue);
        }

        CGSize imageSizePixels = [self calculateImageSizePixels];
        if (imageSizePixels.width <= 0 || imageSizePixels.height <= 0) {
            return CGSizeZero;
        }
        self.cachedImageWidth = @(imageSizePixels.width);
        self.cachedImageHeight = @(imageSizePixels.height);

        if (self.canAsyncUpdate) {
            [self applyChangeAsyncToLatestCopyWithChangeBlock:^(TSAttachmentStream *latestInstance) {
                latestInstance.cachedImageWidth = @(imageSizePixels.width);
                latestInstance.cachedImageHeight = @(imageSizePixels.height);
            }];
        }

        return imageSizePixels;
    }
}

- (CGSize)cachedMediaSize
{
    OWSAssertDebug(self.shouldHaveImageSize);

    @synchronized(self) {
        if (self.cachedImageWidth && self.cachedImageHeight) {
            return CGSizeMake(self.cachedImageWidth.floatValue, self.cachedImageHeight.floatValue);
        } else {
            return CGSizeZero;
        }
    }
}

#pragma mark - Update With...

- (void)applyChangeAsyncToLatestCopyWithChangeBlock:(void (^)(TSAttachmentStream *))changeBlock
{
    OWSAssertDebug(changeBlock);

    NSString *uniqueId = self.uniqueId;
    DatabaseStorageAsyncWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *tx) {
        // We load a new instance before using anyUpdateWithTransaction() since it
        // isn't thread-safe to mutate the current instance async.
        TSAttachmentStream *_Nullable latestInstance = [TSAttachmentStream anyFetchAttachmentStreamWithUniqueId:uniqueId
                                                                                                    transaction:tx];
        if (latestInstance == nil) {
            // This attachment has either not yet been saved or has been deleted; do
            // nothing. This isn't an error per se, but these race conditions should be
            // _very_ rare.
            //
            // An exception is incoming group avatar updates which we don't ever save.
            OWSLogWarn(@"Could not load attachment.");
            return;
        }
        changeBlock(latestInstance);
        [latestInstance anyOverwritingUpdateWithTransaction:tx];
    });
}

#pragma mark -

- (NSTimeInterval)calculateAudioDurationSeconds
{
    OWSAssertDebug([self isAudioMimeType]);

    if (AppContextObjcBridge.CurrentAppContext.isRunningTests) {
        // Return an arbitrary non-zero value to avoid
        // expected exceptions in AVFoundation.
        return 1;
    }

    NSError *error;
    AVAudioPlayer *audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:self.originalMediaURL error:&error];
    if (error && [error.domain isEqualToString:NSOSStatusErrorDomain]
        && (error.code == kAudioFileInvalidFileError || error.code == kAudioFileStreamError_InvalidFile)) {
        // Ignore "invalid audio file" errors.
        return 0;
    }
    if (!error) {
        [audioPlayer prepareToPlay];
        return [audioPlayer duration];
    } else {
        OWSLogError(@"Could not find audio duration: %@", self.originalMediaURL);
        return 0;
    }
}

- (NSTimeInterval)audioDurationSeconds
{
    @synchronized(self) {
        if (self.cachedAudioDurationSeconds) {
            return self.cachedAudioDurationSeconds.doubleValue;
        }

        NSTimeInterval audioDurationSeconds = [self calculateAudioDurationSeconds];
        self.cachedAudioDurationSeconds = @(audioDurationSeconds);

        if (self.canAsyncUpdate) {
            [self applyChangeAsyncToLatestCopyWithChangeBlock:^(TSAttachmentStream *latestInstance) {
                latestInstance.cachedAudioDurationSeconds = @(audioDurationSeconds);
            }];
        }

        return audioDurationSeconds;
    }
}

#pragma mark - Thumbnails

- (void)thumbnailImageWithSizeHint:(CGSize)sizeHint
                           success:(OWSThumbnailSuccess)success
                           failure:(OWSThumbnailFailure)failure
{
    CGFloat maxDimensionHint = MAX(sizeHint.width, sizeHint.height);
    CGFloat thumbnailDimensionPoints;
    if (maxDimensionHint <= TSAttachmentStream.thumbnailDimensionPointsSmall) {
        thumbnailDimensionPoints = TSAttachmentStream.thumbnailDimensionPointsSmall;
    } else if (maxDimensionHint <= TSAttachmentStream.thumbnailDimensionPointsMedium) {
        thumbnailDimensionPoints = TSAttachmentStream.thumbnailDimensionPointsMedium;
    } else {
        thumbnailDimensionPoints = [TSAttachmentStream thumbnailDimensionPointsLarge];
    }

    [self thumbnailImageWithThumbnailDimensionPoints:thumbnailDimensionPoints success:success failure:failure];
}

- (void)thumbnailImageWithQuality:(TSAttachmentThumbnailQuality)quality
                          success:(OWSThumbnailSuccess)success
                          failure:(OWSThumbnailFailure)failure
{
    CGFloat thumbnailDimensionPoints = [TSAttachmentStream thumbnailDimensionPointsForThumbnailQuality:quality];
    [self thumbnailImageWithThumbnailDimensionPoints:thumbnailDimensionPoints success:success failure:failure];
}

- (void)thumbnailImageWithThumbnailDimensionPoints:(CGFloat)thumbnailDimensionPoints
                                           success:(OWSThumbnailSuccess)success
                                           failure:(OWSThumbnailFailure)failure
{
    [self loadedThumbnailWithThumbnailDimensionPoints:thumbnailDimensionPoints
        success:^(OWSLoadedThumbnail *thumbnail) { DispatchMainThreadSafe(^{ success(thumbnail.image); }); }
        failure:^{ DispatchMainThreadSafe(^{ failure(); }); }];
}

- (void)loadedThumbnailWithThumbnailDimensionPoints:(CGFloat)thumbnailDimensionPoints
                                            success:(OWSLoadedThumbnailSuccess)success
                                            failure:(OWSThumbnailFailure)failure
{
    [self.thumbnailLoadingOperationQueue addOperationWithBlock:^{
        @autoreleasepool {
            if (!self.isValidVisualMedia) {
                // Never thumbnail (or try to use the original of) invalid media.
                OWSFailDebug(@"Invalid image.");
                failure();
                return;
            }

            CGSize originalSizePoints = self.imageSizePoints;
            if (self.imageSizePixels.width < 1 || self.imageSizePixels.height < 1) {
                failure();
                return;
            }

            if (originalSizePoints.width <= thumbnailDimensionPoints
                && originalSizePoints.height <= thumbnailDimensionPoints && self.isImageMimeType) {
                // There's no point in generating a thumbnail if the original is smaller than the
                // thumbnail size. Only do this for images. We still need to generate thumbnails
                // for videos.
                NSString *originalFilePath = self.originalFilePath;
                UIImage *_Nullable originalImage = self.originalImage;
                if (originalImage == nil) {
                    OWSFailDebug(@"originalImage was unexpectedly nil");
                    failure();
                } else {
                    success([[OWSLoadedThumbnail alloc] initWithImage:originalImage filePath:originalFilePath]);
                }
                return;
            }

            NSString *thumbnailPath = [self pathForThumbnailDimensionPoints:thumbnailDimensionPoints];
            if ([[NSFileManager defaultManager] fileExistsAtPath:thumbnailPath]) {
                UIImage *_Nullable image = [UIImage imageWithContentsOfFile:thumbnailPath];
                if (!image) {
                    OWSFailDebug(@"couldn't load image.");
                    failure();
                } else {
                    success([[OWSLoadedThumbnail alloc] initWithImage:image filePath:thumbnailPath]);
                }
                return;
            }

            [OWSThumbnailService.shared ensureThumbnailForAttachment:self
                                            thumbnailDimensionPoints:thumbnailDimensionPoints
                                                             success:success
                                                             failure:^(NSError *error) {
                                                                 OWSLogError(@"Failed to create thumbnail: %@", error);
                                                                 failure();
                                                             }];
        }
    }];
    return;
}

- (NSOperationQueue *)thumbnailLoadingOperationQueue
{
    static dispatch_once_t onceToken;
    static NSOperationQueue *operationQueue;
    dispatch_once(&onceToken, ^{
        operationQueue = [NSOperationQueue new];
        operationQueue.name = @"ThumbnailLoading";
        operationQueue.maxConcurrentOperationCount = 4;
    });
    return operationQueue;
}

- (nullable OWSLoadedThumbnail *)loadedThumbnailSyncWithDimensionPoints:(CGFloat)thumbnailDimensionPoints
{
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

    __block OWSLoadedThumbnail *_Nullable asyncLoadedThumbnail = nil;
    [self loadedThumbnailWithThumbnailDimensionPoints:thumbnailDimensionPoints
        success:^(OWSLoadedThumbnail *thumbnail) {
            @synchronized(self) {
                asyncLoadedThumbnail = thumbnail;
            }
            dispatch_semaphore_signal(semaphore);
        }
        failure:^{ dispatch_semaphore_signal(semaphore); }];
    // Wait up to N seconds.
    dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)));
    @synchronized(self) {
        return asyncLoadedThumbnail;
    }
}

- (nullable UIImage *)thumbnailImageSyncWithQuality:(TSAttachmentThumbnailQuality)quality
{
    CGFloat thumbnailDimensionPoints = [TSAttachmentStream thumbnailDimensionPointsForThumbnailQuality:quality];
    OWSLoadedThumbnail *_Nullable loadedThumbnail =
        [self loadedThumbnailSyncWithDimensionPoints:thumbnailDimensionPoints];
    if (!loadedThumbnail) {
        OWSLogInfo(@"Couldn't load %@ thumbnail sync.", NSStringForAttachmentThumbnailQuality(quality));
        return nil;
    }
    return loadedThumbnail.image;
}

- (nullable NSData *)thumbnailDataSmallSync
{
    OWSLoadedThumbnail *_Nullable loadedThumbnail =
        [self loadedThumbnailSyncWithDimensionPoints:TSAttachmentStream.thumbnailDimensionPointsSmall];
    if (!loadedThumbnail) {
        OWSLogInfo(@"Couldn't load small thumbnail sync.");
        return nil;
    }
    NSError *error;
    NSData *_Nullable data = [loadedThumbnail dataAndReturnError:&error];
    if (error || !data) {
        OWSFailDebug(@"Couldn't load thumbnail data: %@", error);
        return nil;
    }
    return data;
}

- (NSArray<NSString *> *)allSecondaryFilePaths
{
    NSMutableArray<NSString *> *result = [NSMutableArray new];

    NSString *thumbnailsDirPath = self.thumbnailsDirPath;
    if ([[NSFileManager defaultManager] fileExistsAtPath:thumbnailsDirPath]) {
        NSError *error;
        NSArray<NSString *> *_Nullable fileNames =
            [[NSFileManager defaultManager] contentsOfDirectoryAtPath:thumbnailsDirPath error:&error];
        if (error || !fileNames) {
            OWSFailDebug(@"contentsOfDirectoryAtPath failed with error: %@", error);
        } else {
            for (NSString *fileName in fileNames) {
                NSString *filePath = [thumbnailsDirPath stringByAppendingPathComponent:fileName];
                [result addObject:filePath];
            }
        }
    }

    NSString *_Nullable audioWaveformPath = self.audioWaveformPath;
    if (audioWaveformPath != nil && [[NSFileManager defaultManager] fileExistsAtPath:audioWaveformPath]) {
        [result addObject:audioWaveformPath];
    }

    return result;
}

#pragma mark - Update With... Methods

- (void)updateAsUploadedWithEncryptionKey:(NSData *)encryptionKey
                                   digest:(NSData *)digest
                                 serverId:(UInt64)serverId
                                   cdnKey:(NSString *)cdnKey
                                cdnNumber:(UInt32)cdnNumber
                          uploadTimestamp:(unsigned long long)uploadTimestamp
                              transaction:(SDSAnyWriteTransaction *)transaction
{
    OWSAssertDebug(encryptionKey.length > 0);
    OWSAssertDebug(digest.length > 0);
    OWSAssertDebug(serverId > 0 || cdnKey.length > 0);
    OWSAssertDebug(uploadTimestamp > 0);

    [self anyUpdateAttachmentStreamWithTransaction:transaction
                                             block:^(TSAttachmentStream *attachment) {
                                                 [attachment setEncryptionKey:encryptionKey];
                                                 [attachment setDigest:digest];
                                                 [attachment setServerId:serverId];
                                                 [attachment setCdnKey:cdnKey];
                                                 [attachment setCdnNumber:cdnNumber];
                                                 [attachment setUploadTimestamp:uploadTimestamp];
                                                 [attachment setIsUploaded:YES];
                                             }];
}

- (nullable TSAttachmentStream *)cloneAsThumbnail
{
    if (!self.isValidVisualMedia) {
        return nil;
    }

    NSString *thumbnailMimeType = [OWSThumbnailService thumbnailMimetypeForContentType:self.contentType];
    NSString *thumbnailFileExtension = [OWSThumbnailService thumbnailFileExtensionForContentType:self.contentType];

    NSData *_Nullable thumbnailData = self.thumbnailDataSmallSync;
    //  Only some media types have thumbnails
    if (!thumbnailData) {
        return nil;
    }

    // Copy the thumbnail to a new attachment.
    NSString *thumbnailName =
        [NSString stringWithFormat:@"quoted-thumbnail-%@.%@", self.sourceFilename, thumbnailFileExtension];
    TSAttachmentStream *thumbnailAttachment =
        [[TSAttachmentStream alloc] initWithContentType:thumbnailMimeType
                                              byteCount:(uint32_t)thumbnailData.length
                                         sourceFilename:thumbnailName
                                                caption:nil
                                         attachmentType:TSAttachmentTypeDefault
                                         albumMessageId:nil];

    NSError *error;
    BOOL success = [thumbnailAttachment writeData:thumbnailData error:&error];
    if (!success || error) {
        OWSLogError(@"Couldn't copy attachment data for message sent to self: %@.", error);
        return nil;
    }

    return thumbnailAttachment;
}

// MARK: Protobuf serialization

- (nullable SSKProtoAttachmentPointer *)buildProto
{
    BOOL isValidV1orV2 = self.serverId > 0;
    BOOL isValidV3 = (self.cdnKey.length > 0 && self.cdnNumber > 0);
    OWSAssertDebug(isValidV1orV2 || isValidV3);

    SSKProtoAttachmentPointerBuilder *builder = [SSKProtoAttachmentPointer builder];
    if (isValidV1orV2) {
        builder.cdnID = self.serverId;
    } else if (isValidV3) {
        builder.cdnKey = self.cdnKey;
        builder.cdnNumber = self.cdnNumber;
    }

    OWSAssertDebug(self.contentType.length > 0);
    builder.contentType = self.contentType;

    if (self.sourceFilename.length > 0) {
        builder.fileName = self.sourceFilename;
    }
    if (self.caption && self.caption.length > 0) {
        builder.caption = self.caption;
    }

    builder.size = self.byteCount;
    builder.key = self.encryptionKey;
    builder.digest = self.digest;

    if (self.clientUuid != nil) {
        NSUUID *_Nullable clientUuid = [[NSUUID alloc] initWithUUIDString:self.clientUuid];

        if (clientUuid != nil) {
            builder.clientUuid = [clientUuid asData];
        } else {
            OWSFailDebug(@"TSAttachment clientUuid unexpectedly failed to convert to UUID!");
        }
    }

    if (self.attachmentType == TSAttachmentTypeVoiceMessage) {
        builder.flags = SSKProtoAttachmentPointerFlagsVoiceMessage;
    } else if (self.attachmentType == TSAttachmentTypeBorderless) {
        builder.flags = SSKProtoAttachmentPointerFlagsBorderless;
    } else if (self.attachmentType == TSAttachmentTypeGIF || self.isAnimatedContent) {
        builder.flags = SSKProtoAttachmentPointerFlagsGif;
    } else {
        builder.flags = 0;
    }

    if (self.blurHash.length > 0) {
        builder.blurHash = self.blurHash;
    }
    if (self.uploadTimestamp > 0) {
        builder.uploadTimestamp = self.uploadTimestamp;
    }

    if (self.shouldHaveImageSize) {
        CGSize imageSizePixels = self.imageSizePixels;
        if (imageSizePixels.width < NSIntegerMax && imageSizePixels.height < NSIntegerMax) {
            NSInteger imageWidth = (NSInteger)round(imageSizePixels.width);
            NSInteger imageHeight = (NSInteger)round(imageSizePixels.height);
            if (imageWidth > 0 && imageHeight > 0) {
                builder.width = (UInt32)imageWidth;
                builder.height = (UInt32)imageHeight;
            }
        }
    }

    return [builder buildInfallibly];
}

@end

NS_ASSUME_NONNULL_END
