//
//  ResignAppOperation.swift
//  AltStore
//
//  Created by Riley Testut on 6/7/19.
//  Copyright © 2019 Riley Testut. All rights reserved.
//

import Foundation
import Roxas
import AltStoreCore
import AltSign

@objc(ResignAppOperation)
final class ResignAppOperation: ResultOperation<ALTApplication>
{
    let context: InstallAppOperationContext
    
    init(context: InstallAppOperationContext)
    {
        self.context = context
        
        super.init()
        
        self.progress.totalUnitCount = 3
    }
    
    override func main()
    {
        super.main()
        
        if let error = self.context.error
        {
            self.finish(.failure(error))
            return
        }
        
        guard
            let app = self.context.app,
            let profiles = self.context.provisioningProfiles,
            let team = self.context.team,
            let certificate = self.context.certificate
        else {
            return self.finish(.failure(OperationError.invalidParameters("ResignAppOperation.main: " +
                                                                         "self.context.team or " +
                                                                         "self.context.provisioningProfiles or" +
                                                                         "self.context.certificate is nil")))
        }
        
        Logger.sideload.notice("Resigning app \(self.context.bundleIdentifier, privacy: .public)...")
        
        // Prepare app bundle
        let prepareAppProgress = Progress.discreteProgress(totalUnitCount: 2)
        self.progress.addChild(prepareAppProgress, withPendingUnitCount: 3)
        
        let effectiveBundleId = self.context.bundleIdentifier
        let prepareAppBundleProgress = self.prepareAppBundle(for: app, profiles: profiles, appexBundleIds: context.appexBundleIds ?? [:]) { (result) in
            guard let appBundleURL = self.process(result) else { return }
            
            // Resign app bundle
            let resignProgress = self.resignAppBundle(at: appBundleURL, team: team, certificate: certificate, profiles: Array(profiles.values)) { (result) in
                guard let resignedURL = self.process(result) else { return }
                
                // Finish
                do
                {
                    let updatedApp = AnyApp(
                        name: app.name,
                        bundleIdentifier: effectiveBundleId,
                        url: app.fileURL,
                        storeApp: app.storeApp
                    )
                    let destinationURL = InstalledApp.refreshedIPAURL(for: updatedApp)
                    try FileManager.default.copyItem(at: resignedURL, to: destinationURL, shouldReplace: true)
                    print("Successfully resigned app to \(destinationURL.absoluteString)")
                    
                    // Use appBundleURL since we need an app bundle, not .ipa.
                    guard let resignedApplication = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
                    
                    Logger.sideload.notice("Resigned app \(self.context.bundleIdentifier, privacy: .public) to \(resignedApplication.bundleIdentifier, privacy: .public).")
                    
                    self.finish(.success(resignedApplication))
                }
                catch
                {
                    self.finish(.failure(error))
                }
            }
            prepareAppProgress.addChild(resignProgress, withPendingUnitCount: 1)
        }
        prepareAppProgress.addChild(prepareAppBundleProgress, withPendingUnitCount: 1)
    }
    
    func process<T>(_ result: Result<T, Error>) -> T?
    {
        switch result
        {
        case .failure(let error):
            self.finish(.failure(error))
            return nil
            
        case .success(let value):
            guard !self.isCancelled else {
                self.finish(.failure(OperationError.cancelled))
                return nil
            }
            
            return value
        }
    }
}

private extension ResignAppOperation
{
    func prepareAppBundle(for app: ALTApplication, profiles: [String: ALTProvisioningProfile], appexBundleIds: [String: String], completionHandler: @escaping (Result<URL, Error>) -> Void) -> Progress
    {
        let progress = Progress.discreteProgress(totalUnitCount: 1)

        let bundleIdentifier = context.bundleIdentifier
        let openURL = InstalledApp.openAppURL(for: app)
        let fileURL = app.fileURL

        func prepare(_ bundle: Bundle, bundleID identifier: String?, additionalInfoDictionaryValues: [String: Any] = [:]) throws
        {
            guard let identifier else { throw ALTError(.missingAppBundle) }
            guard let profile = context.useMainProfile ? profiles.values.first : profiles[identifier] else { throw ALTError(.missingProvisioningProfile) }
            guard var infoDictionary = bundle.completeInfoDictionary else { throw ALTError(.missingInfoPlist) }
            
            if let forcedBundleIdentifier = appexBundleIds[identifier] {
                infoDictionary[kCFBundleIdentifierKey as String] = forcedBundleIdentifier
            } else {
                infoDictionary[kCFBundleIdentifierKey as String] = profile.bundleIdentifier
            }

            infoDictionary[Bundle.Info.altBundleID] = identifier
            infoDictionary[Bundle.Info.devicePairingString] = "<insert pairing file here>"
            infoDictionary.removeValue(forKey: "DTXcode")
            infoDictionary.removeValue(forKey: "DTXcodeBuild")

            for (key, value) in additionalInfoDictionaryValues
            {
                infoDictionary[key] = value
            }

            if let appGroups = profile.entitlements[.appGroups] as? [String]
            {
                infoDictionary[Bundle.Info.appGroups] = appGroups

                // To keep file providers working, remap the NSExtensionFileProviderDocumentGroup, if there is one.
                if var extensionInfo = infoDictionary["NSExtension"] as? [String: Any],
                    let appGroup = extensionInfo["NSExtensionFileProviderDocumentGroup"] as? String,
                    let localAppGroup = appGroups.filter({ $0.contains(appGroup) }).min(by: { $0.count < $1.count })
                {
                    extensionInfo["NSExtensionFileProviderDocumentGroup"] = localAppGroup
                    infoDictionary["NSExtension"] = extensionInfo
                }
            }
            
            // Add app-specific exported UTI so we can check later if this app (extension) is installed or not.
            let installedAppUTI = ["UTTypeConformsTo": [],
                                   "UTTypeDescription": "AltStore Installed App",
                                   "UTTypeIconFiles": [],
                                   "UTTypeIdentifier": InstalledApp.installedAppUTI(forBundleIdentifier: profile.bundleIdentifier),
                                   "UTTypeTagSpecification": [:]] as [String : Any]
            
            var exportedUTIs = infoDictionary[Bundle.Info.exportedUTIs] as? [[String: Any]] ?? []
            exportedUTIs.append(installedAppUTI)
            infoDictionary[Bundle.Info.exportedUTIs] = exportedUTIs
            
            try (infoDictionary as NSDictionary).write(to: bundle.infoPlistURL)
            
            // Remove _CodeSignature folder (if it exists) because it will be added when resigning and it may have files that aren't overwritten when resigning
            // These files might be the cause of some ApplicationVerificationFailed errors
            let codeSignaturePath = bundle.bundleURL.appendingPathComponent("_CodeSignature").absoluteString.replacingOccurrences(of: "file://", with: "")
            if FileManager.default.fileExists(atPath: codeSignaturePath) {
                try FileManager.default.removeItem(atPath: codeSignaturePath)
                print("Removed _CodeSignature folder at \(codeSignaturePath)")
            }
        }
        
        DispatchQueue.global().async {
            do
            {
                let appBundleURL = self.context.temporaryDirectory.appendingPathComponent("App.app")
                try FileManager.default.copyItem(at: fileURL, to: appBundleURL)
                
                // Become current so we can observe progress from unzipAppBundle().
                progress.becomeCurrent(withPendingUnitCount: 1)
                
                guard let appBundle = Bundle(url: appBundleURL) else { throw ALTError(.missingAppBundle) }
                guard let infoDictionary = appBundle.completeInfoDictionary else { throw ALTError(.missingInfoPlist) }
                
                var allURLSchemes = infoDictionary[Bundle.Info.urlTypes] as? [[String: Any]] ?? []
                
                let altstoreURLScheme = ["CFBundleTypeRole": "Editor",
                                         "CFBundleURLName": bundleIdentifier,
                                         "CFBundleURLSchemes": [openURL.scheme!]] as [String : Any]
                allURLSchemes.append(altstoreURLScheme)
                
                var additionalValues: [String: Any] = [Bundle.Info.urlTypes: allURLSchemes]

                if app.isAltStoreApp
                {
                    guard let udid = fetchUDID() else { throw OperationError.unknownUDID }
                    guard Bundle.main.object(forInfoDictionaryKey: Bundle.Info.devicePairingString) is String else { throw OperationError.unknownUDID }
                    additionalValues[Bundle.Info.devicePairingString] = "<insert pairing file here>"
                    additionalValues[Bundle.Info.deviceID] = udid
                    additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID
                    
                    if
                        let data = Keychain.shared.signingCertificate,
                        let signingCertificate = ALTCertificate(p12Data: data, password: nil),
                        let encryptingPassword = Keychain.shared.signingCertificatePassword
                    {
                        additionalValues[Bundle.Info.certificateID] = signingCertificate.serialNumber
                        
                        let encryptedData = signingCertificate.encryptedP12Data(withPassword: encryptingPassword)
                        try encryptedData?.write(to: appBundle.certificateURL, options: .atomic)
                    }
                    else
                    {
                        // The embedded certificate + certificate identifier are already in app bundle, no need to update them.
                    }
                }
                else if infoDictionary.keys.contains(Bundle.Info.deviceID), let udid = fetchUDID()
                {
                    // There is an ALTDeviceID entry, so assume the app is using AltKit and replace it with the device's UDID.
                    additionalValues[Bundle.Info.deviceID] = udid
                    additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID
                }
                
                let iconScale = Int(UIScreen.main.scale)
                
                if let alternateIconURL = self.context.alternateIconURL,
                   case let data = try Data(contentsOf: alternateIconURL),
                   let image = UIImage(data: data),
                   let icon = image.resizing(toFill: CGSize(width: 60 * iconScale, height: 60 * iconScale)),
                   let iconData = icon.pngData()
                {
                    let iconName = "AltIcon"
                    let iconURL = appBundleURL.appendingPathComponent(iconName + "@\(iconScale)x.png")
                    try iconData.write(to: iconURL, options: .atomic)
                    
                    let iconDictionary = ["CFBundlePrimaryIcon": ["CFBundleIconFiles": [iconName]]]
                    additionalValues["CFBundleIcons"] = iconDictionary
                }
                
                // Prepare app
                try prepare(appBundle, bundleID: bundleIdentifier, additionalInfoDictionaryValues: additionalValues)
                try self.removeMissingAppExtensionReferences(from: appBundle)
                
                if let directory = appBundle.builtInPlugInsURL, let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil, options: [.skipsSubdirectoryDescendants])
                {
                    for case let fileURL as URL in enumerator
                    {
                        // for both sim and device, in debug mode builds, remove the tests bundles (if any)
                        #if DEBUG
                        guard !fileURL.lastPathComponent.lowercased().contains(".xctest") else {
                            // Remove embedded XCTest (+ dSYM) bundle from copied app bundle.
                            try FileManager.default.removeItem(at: fileURL)
                            continue
                        }
                        #endif
                        
                        guard let appExtension = Bundle(url: fileURL) else { throw ALTError(.missingAppBundle) }
                        let updatedAppExBundleId = appExtension.bundleIdentifier?.replacingOccurrences(of: app.bundleIdentifier, with: bundleIdentifier)
                        try prepare(appExtension, bundleID: updatedAppExBundleId)
                    }
                }
                
                completionHandler(.success(appBundleURL))
            }
            catch
            {
                completionHandler(.failure(error))
            }
        }
        
        return progress
    }
    
    func resignAppBundle(at fileURL: URL, team: ALTTeam, certificate: ALTCertificate, profiles: [ALTProvisioningProfile], completionHandler: @escaping (Result<URL, Error>) -> Void) -> Progress
    {
        let signer = ALTSigner(team: team, certificate: certificate)
        let progress = signer.signApp(at: fileURL, provisioningProfiles: profiles) { (success, error) in
            do
            {
                try Result(success, error).get()
                
                let ipaURL = try FileManager.default.zipAppBundle(at: fileURL)
                completionHandler(.success(ipaURL))
            }
            catch
            {
                completionHandler(.failure(error))
            }
        }
        
        return progress
    }
    
    func removeMissingAppExtensionReferences(from bundle: Bundle) throws
    {
        // If app extensions have been removed from an app (either by AltStore or the developer),
        // we must remove all references to them from SC_Info/Manifest.plist (if it exists).
        
        let scInfoURL = bundle.bundleURL.appendingPathComponent("SC_Info")
        let manifestPlistURL = scInfoURL.appendingPathComponent("Manifest.plist")
        
        guard let manifestPlist = NSMutableDictionary(contentsOf: manifestPlistURL), let sinfReplicationPaths = manifestPlist["SinfReplicationPaths"] as? [String] else { return }
        
        // Remove references to missing files.
        let filteredReplicationPaths = sinfReplicationPaths.filter { path in
            guard let fileURL = URL(string: path, relativeTo: bundle.bundleURL) else { return false }
            
            let fileExists = FileManager.default.fileExists(atPath: fileURL.path)
            return fileExists
        }
        
        manifestPlist["SinfReplicationPaths"] = filteredReplicationPaths
        
        // Save updated Manifest.plist to disk.
        try manifestPlist.write(to: manifestPlistURL)
    }
}
