//
//  Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// stringlint:disable

import UIKit
import Combine
import MobileCoreServices
import AVFoundation
import UniformTypeIdentifiers
import SessionUtilitiesKit

public enum SignalAttachmentError: Error {
    case missingData
    case fileSizeTooLarge
    case invalidData
    case couldNotParseImage
    case couldNotConvertToJpeg
    case couldNotConvertToMpeg4
    case couldNotRemoveMetadata
    case invalidFileFormat
    case couldNotResizeImage
}

extension String {
    public var filenameWithoutExtension: String {
        return (self as NSString).deletingPathExtension
    }

    public var fileExtension: String? {
        return (self as NSString).pathExtension
    }

    public func appendingFileExtension(_ fileExtension: String) -> String {
        guard let result = (self as NSString).appendingPathExtension(fileExtension) else {
            return self
        }
        return result
    }
}

extension SignalAttachmentError: LocalizedError {
    public var errorDescription: String? {
        switch self {
            case .fileSizeTooLarge:
                return "attachmentsErrorSize".localized()
            case .invalidData, .missingData, .invalidFileFormat:
                return "attachmentsErrorNotSupported".localized()
            case .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, .couldNotResizeImage:
                return "attachmentsErrorOpen".localized()
            case .couldNotRemoveMetadata:
                return "attachmentsImageErrorMetadata".localized()
        }
    }
}

@objc
public enum TSImageQualityTier: UInt {
    case original
    case high
    case mediumHigh
    case medium
    case mediumLow
    case low
}

@objc
public enum TSImageQuality: UInt {
    case original
    case medium
    case compact

    func imageQualityTier() -> TSImageQualityTier {
        switch self {
        case .original:
            return .original
        case .medium:
            return .mediumHigh
        case .compact:
            return .medium
        }
    }
}

// Represents a possible attachment to upload.
// The attachment may be invalid.
//
// Signal attachments are subject to validation and 
// in some cases, file format conversion.
//
// This class gathers that logic.  It offers factory methods
// for attachments that do the necessary work. 
//
// The return value for the factory methods will be nil if the input is nil.
//
// [SignalAttachment hasError] will be true for non-valid attachments.
//
// TODO: Perhaps do conversion off the main thread?
public class SignalAttachment: Equatable {

    // MARK: Properties

    public let dataSource: (any DataSource)
    public var captionText: String?
    public var linkPreviewDraft: LinkPreviewDraft?
    
    public var data: Data { return dataSource.data }
    public var dataLength: UInt { return UInt(dataSource.dataLength) }
    public var dataUrl: URL? { return dataSource.dataUrl }
    public var sourceFilename: String? { return dataSource.sourceFilename?.filteredFilename }
    public var isValidImage: Bool { return dataSource.isValidImage }
    public var isValidVideo: Bool { return dataSource.isValidVideo }

    // This flag should be set for text attachments that can be sent as text messages.
    public var isConvertibleToTextMessage = false

    // This flag should be set for attachments that can be sent as contact shares.
    public var isConvertibleToContactShare = false

    // Attachment types are identified using UTType.
    public let dataType: UTType

    public var error: SignalAttachmentError? {
        didSet {
            assert(oldValue == nil)
        }
    }

    // To avoid redundant work of repeatedly compressing/uncompressing
    // images, we cache the UIImage associated with this attachment if
    // possible.
    private var cachedImage: UIImage?
    private var cachedVideoPreview: UIImage?

    private(set) public var isVoiceMessage = false

    // MARK: 

    public static let maxAttachmentsAllowed: Int = 32

    // MARK: Constructor

    // This method should not be called directly; use the factory
    // methods instead.
    private init(dataSource: (any DataSource), dataType: UTType) {
        self.dataSource = dataSource
        self.dataType = dataType
    }

    // MARK: Methods

    public var hasError: Bool { return error != nil }

    public var errorName: String? {
        guard let error = error else {
            // This method should only be called if there is an error.
            return nil
        }

        return "\(error)"
    }

    public var localizedErrorDescription: String? {
        guard let error = self.error else {
            // This method should only be called if there is an error.
            return nil
        }
        guard let errorDescription = error.errorDescription else {
            return nil
        }

        return "\(errorDescription)"
    }

    public class var missingDataErrorMessage: String {
        guard let errorDescription = SignalAttachmentError.missingData.errorDescription else {
            return ""
        }
        
        return errorDescription
    }

    public func staticThumbnail() -> UIImage? {
        if isAnimatedImage {
            return image()
        }
        else if isImage {
            return image()
        }
        else if isVideo {
            return videoPreview()
        }
        else if isAudio {
            return nil
        }
        
        return nil
    }

    public func image() -> UIImage? {
        if let cachedImage = cachedImage {
            return cachedImage
        }
        guard let image = UIImage(data: dataSource.data) else {
            return nil
        }
        
        cachedImage = image
        return image
    }

    public func videoPreview() -> UIImage? {
        if let cachedVideoPreview = cachedVideoPreview {
            return cachedVideoPreview
        }

        guard let mediaUrl = dataUrl else {
            return nil
        }

        do {
            let filePath = mediaUrl.path
            guard FileManager.default.fileExists(atPath: filePath) else {
                return nil
            }

            let asset = AVURLAsset(url: mediaUrl)
            let generator = AVAssetImageGenerator(asset: asset)
            generator.appliesPreferredTrackTransform = true
            let cgImage = try generator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
            let image = UIImage(cgImage: cgImage)

            cachedVideoPreview = image
            return image

        } catch {
            return nil
        }
    }
    
    public func text() -> String? {
        guard let text = String(data: dataSource.data, encoding: .utf8) else {
            return nil
        }
        
        return text
    }
    
    public func duration() -> TimeInterval? {
        switch (isAudio, isVideo) {
            case (true, _):
                let audioPlayer: AVAudioPlayer? = try? AVAudioPlayer(data: dataSource.data)
                
                return (audioPlayer?.duration).map { $0 > 0 ? $0 : nil }
                
            case (_, true):
                return dataUrl.map { url in
                    let asset: AVURLAsset = AVURLAsset(url: url, options: nil)
                    
                    // According to the CMTime docs "value/timescale = seconds"
                    return (TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale))
                }
                
            default: return nil
        }
    }

    // Returns the MIME type for this attachment or nil if no MIME type
    // can be identified.
    public var mimeType: String {
        guard
            let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension,
            !fileExtension.isEmpty,
            let fileExtensionMimeType: String = UTType(sessionFileExtension: fileExtension)?.preferredMIMEType
        else { return (dataType.preferredMIMEType ?? UTType.mimeTypeDefault) }
        
        // UTI types are an imperfect means of representing file type;
        // file extensions are also imperfect but far more reliable and
        // comprehensive so we always prefer to try to deduce MIME type
        // from the file extension.
        return fileExtensionMimeType
    }

    // Use the filename if known. If not, e.g. if the attachment was copy/pasted, we'll generate a filename
    // like: "signal-2017-04-24-095918.zip"
    public var filenameOrDefault: String {
        if let filename = sourceFilename {
            return filename.filteredFilename
        } else {
            let kDefaultAttachmentName = "signal"

            let dateFormatter = DateFormatter()
            dateFormatter.dateFormat = "YYYY-MM-dd-HHmmss"
            let dateString = dateFormatter.string(from: Date())

            let withoutExtension = "\(kDefaultAttachmentName)-\(dateString)"
            if let fileExtension = self.fileExtension {
                return "\(withoutExtension).\(fileExtension)"
            }

            return withoutExtension
        }
    }

    // Returns the file extension for this attachment or nil if no file extension
    // can be identified.
    public var fileExtension: String? {
        guard
            let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension,
            !fileExtension.isEmpty
        else { return dataType.sessionFileExtension(sourceFilename: sourceFilename) }
        
        return fileExtension.filteredFilename
    }

    public var isImage: Bool { dataType.isImage || dataType.isAnimated }
    public var isAnimatedImage: Bool { dataType.isAnimated }
    public var isVideo: Bool { dataType.isVideo }
    public var isAudio: Bool { dataType.isAudio }

    public var isText: Bool {
        isConvertibleToTextMessage &&
        dataType.conforms(to: .text)
    }

    public var isUrl: Bool {
        dataType.conforms(to: .url)
    }

    public class func pasteboardHasPossibleAttachment() -> Bool {
        return UIPasteboard.general.numberOfItems > 0
    }

    public class func pasteboardHasText() -> Bool {
        guard
            UIPasteboard.general.numberOfItems > 0,
            let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)),
            let pasteboardUTTypes: Set<UTType> = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) })
        else { return false }

        // The pasteboard can be populated with multiple UTI types
        // with different payloads.  iMessage for example will copy
        // an animated GIF to the pasteboard with the following UTI
        // types:
        //
        // * "public.url-name"
        // * "public.utf8-plain-text"
        // * "com.compuserve.gif"
        //
        // We want to paste the animated GIF itself, not it's name.
        //
        // In general, our rule is to prefer non-text pasteboard
        // contents, so we return true IFF there is a text UTI type
        // and there is no non-text UTI type.
        guard !pasteboardUTTypes.contains(where: { !$0.conforms(to: .text) }) else { return false }
        
        return pasteboardUTTypes.contains(where: { $0.conforms(to: .text) || $0.conforms(to: .url) })
    }

    // Returns an attachment from the pasteboard, or nil if no attachment
    // can be found.
    //
    // NOTE: The attachment returned by this method may not be valid.
    //       Check the attachment's error property.
    public class func attachmentFromPasteboard() -> SignalAttachment? {
        guard
            UIPasteboard.general.numberOfItems > 0,
            let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)),
            let pasteboardUTTypes: Set<UTType> = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) })
        else { return nil }
        
        for type in UTType.supportedInputImageTypes {
            if pasteboardUTTypes.contains(type) {
                guard let data: Data = dataForFirstPasteboardItem(type: type) else { return nil }
                
                // Pasted images _SHOULD _NOT_ be resized, if possible.
                let dataSource = DataSourceValue(data: data, dataType: type)
                return attachment(dataSource: dataSource, type: type, imageQuality: .original)
            }
        }
        for type in UTType.supportedVideoTypes {
            if pasteboardUTTypes.contains(type) {
                guard let data = dataForFirstPasteboardItem(type: type) else { return nil }
                
                let dataSource = DataSourceValue(data: data, dataType: type)
                return videoAttachment(dataSource: dataSource, type: type)
            }
        }
        for type in UTType.supportedAudioTypes {
            if pasteboardUTTypes.contains(type) {
                guard let data = dataForFirstPasteboardItem(type: type) else { return nil }
                
                let dataSource = DataSourceValue(data: data, dataType: type)
                return audioAttachment(dataSource: dataSource, type: type)
            }
        }

        let type: UTType = pasteboardUTTypes[pasteboardUTTypes.startIndex]
        guard let data = dataForFirstPasteboardItem(type: type) else { return nil }
        
        let dataSource = DataSourceValue(data: data, dataType: type)
        return genericAttachment(dataSource: dataSource, type: type)
    }

    // This method should only be called for dataUTIs that
    // are appropriate for the first pasteboard item.
    private class func dataForFirstPasteboardItem(type: UTType) -> Data? {
        guard
            UIPasteboard.general.numberOfItems > 0,
            let dataValues: [Data] = UIPasteboard.general.data(
                forPasteboardType: type.identifier,
                inItemSet: IndexSet(integer: 0)
            ),
            !dataValues.isEmpty
        else { return nil }
        
        return dataValues[0]
    }

    // MARK: Image Attachments

    // Factory method for an image attachment.
    //
    // NOTE: The attachment returned by this method may not be valid.
    //       Check the attachment's error property.
    private class func imageAttachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> SignalAttachment {
        assert(dataSource != nil)
        guard var dataSource = dataSource else {
            let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
            attachment.error = .missingData
            return attachment
        }

        let attachment = SignalAttachment(dataSource: dataSource, dataType: type)

        guard UTType.supportedInputImageTypes.contains(type) else {
            attachment.error = .invalidFileFormat
            return attachment
        }

        guard dataSource.dataLength > 0 else {
            attachment.error = .invalidData
            return attachment
        }

        if UTType.supportedAnimatedImageTypes.contains(type) {
            guard dataSource.dataLength <= MediaUtils.maxFileSizeAnimatedImage else {
                attachment.error = .fileSizeTooLarge
                return attachment
            }

            // Never re-encode animated images (i.e. GIFs) as JPEGs.
            return attachment
        } else {
            guard let image = UIImage(data: dataSource.data) else {
                attachment.error = .couldNotParseImage
                return attachment
            }
            attachment.cachedImage = image

            let isValidOutput = isValidOutputImage(image: image, dataSource: dataSource, type: type, imageQuality: imageQuality)

            if let sourceFilename = dataSource.sourceFilename,
                let sourceFileExtension = sourceFilename.fileExtension,
                ["heic", "heif"].contains(sourceFileExtension.lowercased()) {

                // If a .heic file actually contains jpeg data, update the extension to match.
                //
                // Here's how that can happen:
                // In iOS11, the Photos.app records photos with HEIC UTIType, with the .HEIC extension.
                // Since HEIC isn't a valid output format for Signal, we'll detect that and convert to JPEG,
                // updating the extension as well. No problem.
                // However the problem comes in when you edit an HEIC image in Photos.app - the image is saved
                // in the Photos.app as a JPEG, but retains the (now incongruous) HEIC extension in the filename.
                assert(type == .jpeg || !isValidOutput)

                let baseFilename = sourceFilename.filenameWithoutExtension
                dataSource.sourceFilename = baseFilename.appendingFileExtension("jpg")
            }

            if isValidOutput {
                return removeImageMetadata(attachment: attachment)
            } else {
                return compressImageAsJPEG(image: image, attachment: attachment, filename: dataSource.sourceFilename, imageQuality: imageQuality)
            }
        }
    }

    // If the proposed attachment already conforms to the
    // file size and content size limits, don't recompress it.
    private class func isValidOutputImage(image: UIImage?, dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> Bool {
        guard
            image != nil,
            let dataSource = dataSource,
            UTType.supportedOutputImageTypes.contains(type)
        else { return false }
        
        return (
            doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) &&
            dataSource.dataLength <= MediaUtils.maxFileSizeImage
        )
    }

    // Factory method for an image attachment.
    //
    // NOTE: The attachment returned by this method may nil or not be valid.
    //       Check the attachment's error property.
    public class func imageAttachment(image: UIImage?, type: UTType, filename: String?, imageQuality: TSImageQuality) -> SignalAttachment {
        guard let image: UIImage = image else {
            let dataSource = DataSourceValue.empty
            dataSource.sourceFilename = filename
            let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
            attachment.error = .missingData
            return attachment
        }

        // Make a placeholder attachment on which to hang errors if necessary.
        let dataSource = DataSourceValue.empty
        dataSource.sourceFilename = filename
        let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
        attachment.cachedImage = image

        return compressImageAsJPEG(image: image, attachment: attachment, filename: filename, imageQuality: imageQuality)
    }

    private class func compressImageAsJPEG(image: UIImage, attachment: SignalAttachment, filename: String?, imageQuality: TSImageQuality) -> SignalAttachment {
        assert(attachment.error == nil)

        if imageQuality == .original &&
            attachment.dataLength < MediaUtils.maxFileSizeGeneric &&
            UTType.supportedOutputImageTypes.contains(attachment.dataType) {
            // We should avoid resizing images attached "as documents" if possible.
            return attachment
        }

        var imageUploadQuality = imageQuality.imageQualityTier()

        while true {
            let maxSize = maxSizeForImage(image: image, imageUploadQuality: imageUploadQuality)
            var dstImage: UIImage! = image
            if image.size.width > maxSize ||
                image.size.height > maxSize {
                guard let resizedImage = imageScaled(image, toMaxSize: maxSize) else {
                    attachment.error = .couldNotResizeImage
                    return attachment
                }
                dstImage = resizedImage
            }
            guard let jpgImageData = dstImage.jpegData(compressionQuality: jpegCompressionQuality(imageUploadQuality: imageUploadQuality)) else {
                attachment.error = .couldNotConvertToJpeg
                return attachment
            }

            let dataSource = DataSourceValue(data: jpgImageData, fileExtension: "jpg")
            let baseFilename = filename?.filenameWithoutExtension
            let jpgFilename = baseFilename?.appendingFileExtension("jpg")
            dataSource.sourceFilename = jpgFilename

            if doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) &&
                dataSource.dataLength <= MediaUtils.maxFileSizeImage {
                let recompressedAttachment = SignalAttachment(dataSource: dataSource, dataType: .jpeg)
                recompressedAttachment.cachedImage = dstImage
                return recompressedAttachment
            }

            // If the JPEG output is larger than the file size limit,
            // continue to try again by progressively reducing the
            // image upload quality.
            switch imageUploadQuality {
            case .original:
                imageUploadQuality = .high
            case .high:
                imageUploadQuality = .mediumHigh
            case .mediumHigh:
                imageUploadQuality = .medium
            case .medium:
                imageUploadQuality = .mediumLow
            case .mediumLow:
                imageUploadQuality = .low
            case .low:
                attachment.error = .fileSizeTooLarge
                return attachment
            }
        }
    }

    // NOTE: For unknown reasons, resizing images with UIGraphicsBeginImageContext()
    // crashes reliably in the share extension after screen lock's auth UI has been presented.
    // Resizing using a CGContext seems to work fine.
    private class func imageScaled(_ uiImage: UIImage, toMaxSize maxSize: CGFloat) -> UIImage? {
        guard let cgImage = uiImage.cgImage else {
            return nil
        }

        // It's essential that we work consistently in "CG" coordinates (which are
        // pixels and don't reflect orientation), not "UI" coordinates (which
        // are points and do reflect orientation).
        let scrSize = CGSize(width: cgImage.width, height: cgImage.height)
        var maxSizeRect = CGRect.zero
        maxSizeRect.size = CGSize(width: maxSize, height: maxSize)
        let newSize = AVMakeRect(aspectRatio: scrSize, insideRect: maxSizeRect).size

        let colorSpace = CGColorSpaceCreateDeviceRGB()
        let bitmapInfo: CGBitmapInfo = [
            CGBitmapInfo(rawValue: CGImageByteOrderInfo.orderDefault.rawValue),
            CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)]
        guard let context = CGContext.init(data: nil,
                                           width: Int(newSize.width),
                                           height: Int(newSize.height),
                                           bitsPerComponent: 8,
                                           bytesPerRow: 0,
                                           space: colorSpace,
                                           bitmapInfo: bitmapInfo.rawValue) else {
            return nil
        }
        context.interpolationQuality = .high

        var drawRect = CGRect.zero
        drawRect.size = newSize
        context.draw(cgImage, in: drawRect)

        guard let newCGImage = context.makeImage() else {
            return nil
        }
        return UIImage(cgImage: newCGImage,
                       scale: uiImage.scale,
                       orientation: uiImage.imageOrientation)
    }

    private class func doesImageHaveAcceptableFileSize(dataSource: (any DataSource), imageQuality: TSImageQuality) -> Bool {
        switch imageQuality {
        case .original:
            return true
        case .medium:
            return dataSource.dataLength < UInt(1024 * 1024)
        case .compact:
            return dataSource.dataLength < UInt(400 * 1024)
        }
    }

    private class func maxSizeForImage(image: UIImage, imageUploadQuality: TSImageQualityTier) -> CGFloat {
        switch imageUploadQuality {
        case .original:
            return max(image.size.width, image.size.height)
        case .high:
            return 2048
        case .mediumHigh:
            return 1536
        case .medium:
            return 1024
        case .mediumLow:
            return 768
        case .low:
            return 512
        }
    }

    private class func jpegCompressionQuality(imageUploadQuality: TSImageQualityTier) -> CGFloat {
        switch imageUploadQuality {
        case .original:
            return 1
        case .high:
            return 0.9
        case .mediumHigh:
            return 0.8
        case .medium:
            return 0.7
        case .mediumLow:
            return 0.6
        case .low:
            return 0.5
        }
    }

    private class func removeImageMetadata(attachment: SignalAttachment) -> SignalAttachment {
        guard let source = CGImageSourceCreateWithData(attachment.data as CFData, nil) else {
            let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: attachment.dataType)
            attachment.error = .missingData
            return attachment
        }

        guard let type = CGImageSourceGetType(source) else {
            let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: attachment.dataType)
            attachment.error = .invalidFileFormat
            return attachment
        }

        let count = CGImageSourceGetCount(source)
        let mutableData = NSMutableData()
        guard let destination = CGImageDestinationCreateWithData(mutableData as CFMutableData, type, count, nil) else {
            attachment.error = .couldNotRemoveMetadata
            return attachment
        }

        let removeMetadataProperties: [String: AnyObject] =
        [
            kCGImagePropertyExifDictionary as String: kCFNull,
            kCGImagePropertyExifAuxDictionary as String: kCFNull,
            kCGImagePropertyGPSDictionary as String: kCFNull,
            kCGImagePropertyTIFFDictionary as String: kCFNull,
            kCGImagePropertyJFIFDictionary as String: kCFNull,
            kCGImagePropertyPNGDictionary as String: kCFNull,
            kCGImagePropertyIPTCDictionary as String: kCFNull,
            kCGImagePropertyMakerAppleDictionary as String: kCFNull
        ]

        for index in 0...count-1 {
            CGImageDestinationAddImageFromSource(destination, source, index, removeMetadataProperties as CFDictionary)
        }

        if CGImageDestinationFinalize(destination) {
            guard let dataSource = DataSourceValue(data: mutableData as Data, dataType: attachment.dataType) else {
                attachment.error = .couldNotRemoveMetadata
                return attachment
            }

            let strippedAttachment = SignalAttachment(dataSource: dataSource, dataType: attachment.dataType)
            return strippedAttachment

        } else {
            attachment.error = .couldNotRemoveMetadata
            return attachment
        }
    }

    // MARK: Video Attachments

    // Factory method for video attachments.
    //
    // NOTE: The attachment returned by this method may not be valid.
    //       Check the attachment's error property.
    private class func videoAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
        guard let dataSource = dataSource else {
            let dataSource = DataSourceValue.empty
            let attachment = SignalAttachment(dataSource: dataSource, dataType: type)
            attachment.error = .missingData
            return attachment
        }

        return newAttachment(
            dataSource: dataSource,
            type: type,
            validTypes: UTType.supportedVideoTypes,
            maxFileSize: MediaUtils.maxFileSizeVideo
        )
    }

    public class func copyToVideoTempDir(url fromUrl: URL) throws -> URL {
        let baseDir = SignalAttachment.videoTempPath.appendingPathComponent(UUID().uuidString, isDirectory: true)
        try? FileSystem.ensureDirectoryExists(at: baseDir.path)
        let toUrl = baseDir.appendingPathComponent(fromUrl.lastPathComponent)

        try FileManager.default.copyItem(at: fromUrl, to: toUrl)

        return toUrl
    }

    private class var videoTempPath: URL {
        let videoDir = URL(fileURLWithPath: Singleton.appContext.temporaryDirectory).appendingPathComponent("video")
        try? FileSystem.ensureDirectoryExists(at: videoDir.path)
        return videoDir
    }

    public class func compressVideoAsMp4(
        dataSource: (any DataSource),
        type: UTType,
        using dependencies: Dependencies
    ) -> (AnyPublisher<SignalAttachment, Error>, AVAssetExportSession?) {
        guard let url = dataSource.dataUrl else {
            let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
            attachment.error = .missingData
            return (
                Just(attachment)
                    .setFailureType(to: Error.self)
                    .eraseToAnyPublisher(),
                nil
            )
        }

        let asset = AVAsset(url: url)

        guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
            let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
            attachment.error = .couldNotConvertToMpeg4
            return (
                Just(attachment)
                    .setFailureType(to: Error.self)
                    .eraseToAnyPublisher(),
                nil
            )
        }

        exportSession.shouldOptimizeForNetworkUse = true
        exportSession.outputFileType = AVFileType.mp4
        exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()

        let exportURL = videoTempPath.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4")
        exportSession.outputURL = exportURL

        let publisher = Deferred {
            Future<SignalAttachment, Error> { resolver in
                exportSession.exportAsynchronously {
                    let baseFilename = dataSource.sourceFilename
                    let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4")
                    
                    guard let dataSource = DataSourcePath(fileUrl: exportURL, sourceFilename: baseFilename, shouldDeleteOnDeinit: true) else {
                        let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
                        attachment.error = .couldNotConvertToMpeg4
                        resolver(Result.success(attachment))
                        return
                    }
                    
                    dataSource.sourceFilename = mp4Filename
                    
                    let attachment = SignalAttachment(dataSource: dataSource, dataType: .mpeg4Movie)
                    resolver(Result.success(attachment))
                }
            }
        }
        .eraseToAnyPublisher()

        return (publisher, exportSession)
    }

    public struct VideoCompressionResult {
        public let attachmentPublisher: AnyPublisher<SignalAttachment, Error>
        public let exportSession: AVAssetExportSession?

        fileprivate init(attachmentPublisher: AnyPublisher<SignalAttachment, Error>, exportSession: AVAssetExportSession?) {
            self.attachmentPublisher = attachmentPublisher
            self.exportSession = exportSession
        }
    }

    public class func compressVideoAsMp4(dataSource: (any DataSource), type: UTType, using dependencies: Dependencies) -> VideoCompressionResult {
        let (attachmentPublisher, exportSession) = compressVideoAsMp4(dataSource: dataSource, type: type, using: dependencies)
        return VideoCompressionResult(attachmentPublisher: attachmentPublisher, exportSession: exportSession)
    }

    public class func isInvalidVideo(dataSource: (any DataSource), type: UTType) -> Bool {
        guard UTType.supportedVideoTypes.contains(type) else {
            // not a video
            return false
        }

        guard isValidOutputVideo(dataSource: dataSource, type: type) else {
            // found a video which needs to be converted
            return true
        }

        // It is a video, but it's not invalid
        return false
    }

    private class func isValidOutputVideo(dataSource: (any DataSource)?, type: UTType) -> Bool {
        guard
            let dataSource = dataSource,
            UTType.supportedOutputVideoTypes.contains(type),
            dataSource.dataLength <= MediaUtils.maxFileSizeVideo
        else { return false }
        
        return false
    }

    // MARK: Audio Attachments

    // Factory method for audio attachments.
    //
    // NOTE: The attachment returned by this method may not be valid.
    //       Check the attachment's error property.
    private class func audioAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
        return newAttachment(
            dataSource: dataSource,
            type: type,
            validTypes: UTType.supportedAudioTypes,
            maxFileSize: MediaUtils.maxFileSizeAudio
        )
    }

    // MARK: Generic Attachments

    // Factory method for generic attachments.
    //
    // NOTE: The attachment returned by this method may not be valid.
    //       Check the attachment's error property.
    private class func genericAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
        return newAttachment(
            dataSource: dataSource,
            type: type,
            validTypes: nil,
            maxFileSize: MediaUtils.maxFileSizeGeneric
        )
    }

    // MARK: Voice Messages

    public class func voiceMessageAttachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
        let attachment = audioAttachment(dataSource: dataSource, type: type)
        attachment.isVoiceMessage = true
        return attachment
    }

    // MARK: Attachments

    // Factory method for non-image Attachments.
    //
    // NOTE: The attachment returned by this method may not be valid.
    //       Check the attachment's error property.
    public class func attachment(dataSource: (any DataSource)?, type: UTType) -> SignalAttachment {
        return attachment(dataSource: dataSource, type: type, imageQuality: .original)
    }

    // Factory method for attachments of any kind.
    //
    // NOTE: The attachment returned by this method may not be valid.
    //       Check the attachment's error property.
    public class func attachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> SignalAttachment {
        if UTType.supportedInputImageTypes.contains(type) {
            return imageAttachment(dataSource: dataSource, type: type, imageQuality: imageQuality)
        } else if UTType.supportedVideoTypes.contains(type) {
            return videoAttachment(dataSource: dataSource, type: type)
        } else if UTType.supportedAudioTypes.contains(type) {
            return audioAttachment(dataSource: dataSource, type: type)
        }
        
        return genericAttachment(dataSource: dataSource, type: type)
    }

    public class func empty() -> SignalAttachment {
        return SignalAttachment.attachment(
            dataSource: DataSourceValue.empty,
            type: .content,
            imageQuality: .original
        )
    }

    // MARK: Helper Methods

    private class func newAttachment(
        dataSource: (any DataSource)?,
        type: UTType,
        validTypes: Set<UTType>?,
        maxFileSize: UInt
    ) -> SignalAttachment {
        assert(dataSource != nil)
        guard let dataSource = dataSource else {
            let attachment = SignalAttachment(dataSource: DataSourceValue.empty, dataType: type)
            attachment.error = .missingData
            return attachment
        }

        let attachment = SignalAttachment(dataSource: dataSource, dataType: type)

        if let validTypes: Set<UTType> = validTypes {
            guard validTypes.contains(type) else {
                attachment.error = .invalidFileFormat
                return attachment
            }
        }

        guard dataSource.dataLength > 0 else {
            assert(dataSource.dataLength > 0)
            attachment.error = .invalidData
            return attachment
        }

        guard dataSource.dataLength <= maxFileSize else {
            attachment.error = .fileSizeTooLarge
            return attachment
        }

        // Attachment is valid
        return attachment
    }
    
    // MARK: - Equatable
    
    public static func == (lhs: SignalAttachment, rhs: SignalAttachment) -> Bool {
        switch (lhs.dataSource, rhs.dataSource) {
            case (let lhsDataSource as DataSourcePath, let rhsDataSource as DataSourcePath):
                guard lhsDataSource == rhsDataSource else { return false }
                break

            case (let lhsDataSource as DataSourceValue, let rhsDataSource as DataSourceValue):
                guard lhsDataSource == rhsDataSource else { return false }
                break

            default: return false
        }
        
        return (
            lhs.dataType == rhs.dataType &&
            lhs.captionText == rhs.captionText &&
            lhs.linkPreviewDraft == rhs.linkPreviewDraft &&
            lhs.isConvertibleToTextMessage == rhs.isConvertibleToTextMessage &&
            lhs.isConvertibleToContactShare == rhs.isConvertibleToContactShare &&
            lhs.cachedImage == rhs.cachedImage &&
            lhs.cachedVideoPreview == rhs.cachedVideoPreview &&
            lhs.isVoiceMessage == rhs.isVoiceMessage
        )
    }
}
