import java.time.LocalDateTime
import org.apache.tools.ant.taskdefs.condition.Os

import static groovy.io.FileType.*

task jpackageSanityChecks {
    description 'Interactive sanity checks on the version of the code that will be packaged'

    doLast {
        executeCmd("git --no-pager log -5 --oneline")
        ant.input(message: "Above you see the current HEAD and its recent history.\n" +
                "Is this the right commit for packaging? (y=continue, n=abort)",
                addproperty: "sanity-check-1",
                validargs: "y,n")
        if (ant.properties['sanity-check-1'] == 'n') {
            ant.fail('Aborting')
        }

        executeCmd("git status --short --branch")
        ant.input(message: "Above you see any local changes that are not in the remote branch.\n" +
                "If you have any local changes, please abort, get them merged, get the latest branch and try again.\n" +
                "Continue with packaging? (y=continue, n=abort)",
                addproperty: "sanity-check-2",
                validargs: "y,n")
        if (ant.properties['sanity-check-2'] == 'n') {
            ant.fail('Aborting')
        }

        // TODO Evtl check programmatically in gradle (i.e. fail if below v11)
        executeCmd("java --version")
        ant.input(message: "Above you see the installed java version, which will be used to compile and build Haveno.\n" +
                "Is this java version ok for that? (y=continue, n=abort)",
                addproperty: "sanity-check-3",
                validargs: "y,n")
        if (ant.properties['sanity-check-3'] == 'n') {
            ant.fail('Aborting')
        }
    }
}

task getJavaBinariesDownloadURLs {
    description 'Find out which JDK will be used for jpackage and prepare to download it'
    dependsOn 'jpackageSanityChecks'

    doLast {
        // The build directory will be deleted next time the clean task runs
        // Therefore, we can use it to store any temp files (separate JDK for jpackage, etc) and resulting build artefacts
        // We create a temp folder in the build directory which holds all jpackage-related artefacts (not just the final installers)
        String tempRootDirName = 'temp-' + LocalDateTime.now().format('yyyy.MM.dd-HHmmssSSS')
        File tempRootDir = new File(project.buildDir, tempRootDirName)
        tempRootDir.mkdirs()
        ext.tempRootDir = tempRootDir
        println "Created temp root folder " + tempRootDir

        File binariesFolderPath = new File(tempRootDir, "binaries")
        binariesFolderPath.mkdirs()
        ext.binariesFolderPath = binariesFolderPath

        // TODO Extend script logic to alternatively allow a local (separate, v14+) JDK for jpackage
        // TODO Another option is to use the local JDK for everything: build jars and use jpackage (but then it has to be v14+)

        // Define the download URLs (and associated binary hashes) for the JDK used to package the installers
        // These JDKs are independent of what is installed on the building system
        //
        // If these specific versions are not hosted by AdoptOpenJDK anymore, or if different versions are desired,
        // simply update the links and associated hashes below
        //
        // See https://adoptopenjdk.net/releases.html?variant=openjdk15&jvmVariant=hotspot for latest download URLs
        // On the download page linked above, filter as follows to get the binary URL + associated SHA256:
        // - architecture: x64
        // - operating system:
        // -- linux   ( -> use the tar.gz JDK link)
        // -- macOS   ( -> use the tar.gz JDK link)
        // -- windows ( -> use the .zip JDK link)
        Map jdk15Binaries = [
                'linux'         : 'https://github.com/AdoptOpenJDK/openjdk15-binaries/releases/download/jdk-15.0.2%2B7/OpenJDK15U-jdk_x64_linux_hotspot_15.0.2_7.tar.gz',
                'linux-sha256'  : '94f20ca8ea97773571492e622563883b8869438a015d02df6028180dd9acc24d',
                'mac'           : 'https://github.com/AdoptOpenJDK/openjdk15-binaries/releases/download/jdk-15.0.2%2B7/OpenJDK15U-jdk_x64_mac_hotspot_15.0.2_7.tar.gz',
                'mac-sha256'    : 'd358a7ff03905282348c6c80562a4da2e04eb377b60ad2152be4c90f8d580b7f',
                'windows'       : 'https://github.com/AdoptOpenJDK/openjdk15-binaries/releases/download/jdk-15.0.2%2B7/OpenJDK15U-jdk_x64_windows_hotspot_15.0.2_7.zip',
                'windows-sha256': 'b80dde2b7f8374eff0f1726c1cbdb48fb095fdde21489046d92f7144baff5741'

                // TODO For some reason, using "--runtime-image jdk-11" does NOT work with a v15 jpackage, but works with v14
        ]

        String osKey
        if (Os.isFamily(Os.FAMILY_WINDOWS)) {
            osKey = 'windows'
        } else if (Os.isFamily(Os.FAMILY_MAC)) {
            osKey = 'mac'
        } else {
            osKey = 'linux'
        }

        ext.jdk15Binary_DownloadURL = jdk15Binaries[osKey]
        ext.jdk15Binary_SHA256Hash = jdk15Binaries[osKey + '-sha256']
    }
}

task retrieveAndExtractJavaBinaries {
    description 'Retrieve necessary Java binaries and extract them'
    dependsOn 'getJavaBinariesDownloadURLs'

    doLast {
        File tempRootDir = getJavaBinariesDownloadURLs.property("tempRootDir") as File

        // Folder where the jpackage JDK archive will be downloaded and extracted
        String jdkForJpackageDirName = "jdk-jpackage"
        File jdkForJpackageDir = new File(tempRootDir, jdkForJpackageDirName)
        jdkForJpackageDir.mkdirs()

        String jdkForJpackageArchiveURL = getJavaBinariesDownloadURLs.property('jdk15Binary_DownloadURL')
        String jdkForJpackageArchiveHash = getJavaBinariesDownloadURLs.property('jdk15Binary_SHA256Hash')
        String jdkForJpackageArchiveFileName = jdkForJpackageArchiveURL.tokenize('/').last()
        File jdkForJpackageFile = new File(jdkForJpackageDir, jdkForJpackageArchiveFileName)

        // Download necessary JDK binaries + verify hash
        ext.downloadAndVerifyArchive(jdkForJpackageArchiveURL, jdkForJpackageArchiveHash, jdkForJpackageFile)

        // Extract them
        String jpackageBinaryFileName
        if (Os.isFamily(Os.FAMILY_WINDOWS)) {
            ext.extractArchiveZip(jdkForJpackageFile, jdkForJpackageDir)
            jpackageBinaryFileName = 'jpackage.exe'
        } else {
            ext.extractArchiveTarGz(jdkForJpackageFile, jdkForJpackageDir)
            jpackageBinaryFileName = 'jpackage'
        }

        // Find jpackage in the newly extracted JDK
        // Don't rely on hardcoded paths to reach it, because the path depends on the version and platform
        jdkForJpackageDir.traverse(type: FILES, nameFilter: jpackageBinaryFileName) {
            println 'Using jpackage binary from ' + it
            ext.jpackageFilePath = it.path
        }
    }

    ext.downloadAndVerifyArchive = { String archiveURL, String archiveSHA256, File destinationArchiveFile ->
        println "Downloading ${archiveURL}"
        ant.get(src: archiveURL, dest: destinationArchiveFile)
        println 'Download saved to ' + destinationArchiveFile

        println 'Verifying checksum for downloaded binary ...'
        ant.jdkHash = archiveSHA256
        ant.checksum(file: destinationArchiveFile, algorithm: 'SHA-256', property: '${jdkHash}', verifyProperty: 'hashMatches')
        if (ant.properties['hashMatches'] != 'true') {
            ant.fail('Checksum mismatch: Downloaded JDK binary has a different checksum than expected')
        }
        println 'Checksum verified'
    }

    ext.extractArchiveTarGz = { File tarGzFile, File destinationDir ->
        println "Extracting tar.gz ${tarGzFile}"
        // Gradle's tar extraction preserves permissions (crucial for jpackage to function correctly)
        copy {
            from tarTree(resources.gzip(tarGzFile))
            into destinationDir
        }
        println "Extracted to ${destinationDir}"
    }

    ext.extractArchiveZip = { File zipFile, File destinationDir ->
        println "Extracting zip ${zipFile}..."
        ant.unzip(src: zipFile, dest: destinationDir)
        println "Extracted to ${destinationDir}"
    }
}

task packageInstallers {
    description 'Call jpackage to prepare platform-specific binaries for this platform'
    dependsOn 'retrieveAndExtractJavaBinaries'
    // Clean all previous artefacts and create a fresh shadowJar for the installers
    dependsOn rootProject.clean
    dependsOn ':desktop:shadowJar'

    doLast {
        String jPackageFilePath = retrieveAndExtractJavaBinaries.property('jpackageFilePath')
        File binariesFolderPath = file(getJavaBinariesDownloadURLs.property('binariesFolderPath'))

        File tempRootDir = getJavaBinariesDownloadURLs.property("tempRootDir") as File
        // The jpackageTempDir stores temp files used by jpackage for building the installers
        // It can be inspected in order to troubleshoot the packaging process
        File jpackageTempDir = new File(tempRootDir, "jpackage-temp")
        jpackageTempDir.mkdirs()

        // ALL contents of this folder will be included in the resulting installers
        // However, the fat jar is the only one we need
        // Therefore, this location should point to a folder that ONLY contains the fat jar
        // If later we will need to include other non-jar resources, we can do that by adding --resource-dir to the jpackage opts
        String fatJarFolderPath = "${project(':desktop').buildDir}/libs/fatJar"
        String mainJarName = shadowJar.getArchiveFileName().get()

        delete(fatJarFolderPath)
        mkdir(fatJarFolderPath)
        copy {
            from "${project(':desktop').buildDir}/libs/${mainJarName}"
            into fatJarFolderPath
        }

        // We convert the fat jar into a deterministic one by stripping out comments with date, etc.
        // jar file created from https://github.com/ManfredKarrer/tools
        executeCmd("java -jar \"${project(':desktop').projectDir}/package/tools-1.0.jar\" ${fatJarFolderPath}/${mainJarName}")

        // Store deterministic jar SHA-256
        ant.checksum(file: "${fatJarFolderPath}/${mainJarName}", algorithm: 'SHA-256')
        copy {
            from "${fatJarFolderPath}/${mainJarName}.SHA-256"
            into binariesFolderPath
        }

        // TODO For non-modular applications: use jlink to create a custom runtime containing only the modules required

        // See jpackager argument documentation:
        // https://docs.oracle.com/en/java/javase/15/docs/specs/man/jpackage.html

        // Remove the -SNAPSHOT suffix from the version string (originally defined in build.gradle)
        // Having it in would have resulted in an invalid version property for several platforms (mac, linux/rpm)
        String appVersion = version.replaceAll("-SNAPSHOT", "")
        println "Packaging Haveno version ${appVersion}"

        // zip jar lib for Raspberry Pi only on macOS as there are path issues on Windows and it is only needed once
        // for the release
        if (Os.isFamily(Os.FAMILY_MAC)) {
            println "Zipping jar lib for raspberry pi"
            ant.zip(basedir: "${project(':desktop').buildDir}/app/lib",
                    destfile: "${binariesFolderPath}/jar-lib-for-raspberry-pi-${appVersion}.zip")
        }

        String appDescription = 'A decentralized bitcoin exchange network.'
        String appCopyright = '© 2021 Haveno'
        String appNameAndVendor = 'Haveno'

        String commonOpts = new String(
                // Generic options
                " --dest \"${binariesFolderPath}\"" +
                        " --name ${appNameAndVendor}" +
                        " --description \"${appDescription}\"" +
                        " --app-version ${appVersion}" +
                        " --copyright \"${appCopyright}\"" +
                        " --vendor ${appNameAndVendor}" +
                        " --temp \"${jpackageTempDir}\"" +

                        // Options for creating the application image
                        " --input ${fatJarFolderPath}" +

                        // Options for creating the application launcher
                        " --main-jar ${mainJarName}" +
                        " --main-class bisq.desktop.app.HavenoAppMain" +
                        " --java-options -Xss1280k" +
                        " --java-options -XX:MaxRAM=4g" +
                        " --java-options -Djava.net.preferIPv4Stack=true"
                // Warning: this will cause guice reflection exceptions and lead to issues with the guice internal cache
                // resulting in the UI not loading
//              " --java-options -Djdk.module.illegalAccess=deny" +
        )

        if (Os.isFamily(Os.FAMILY_WINDOWS)) {
            // TODO Found no benefit in using --resource-dir "..package/windows", it has the same outcome as opts below
            String windowsOpts = new String(
                    " --icon \"${project(':desktop').projectDir}/package/windows/Haveno.ico\"" +
                            " --resource-dir \"${project(':desktop').projectDir}/package/windows\"" +
                            " --win-dir-chooser" +
                            " --win-per-user-install" +
                            " --win-menu" +
                            " --win-shortcut"
            )

            executeCmd(jPackageFilePath + commonOpts + windowsOpts + " --type exe")

            // Set the necessary permissions before calling signtool
            executeCmd("\"attrib -R \"${binariesFolderPath}/Haveno-${appVersion}.exe\"\"")

            // In addition to the groovy quotes around the string, the entire Windows command must also be surrounded
            // by quotes, plus each path inside the command has to be quoted as well
            // Reason for this is that the path to the called executable contains spaces
            // See https://stackoverflow.com/questions/6376113/how-do-i-use-spaces-in-the-command-prompt/6378038#6378038
            executeCmd("\"\"C:\\Program Files (x86)\\Windows Kits\\10\\App Certification Kit\\signtool.exe\" sign /v /fd SHA256 /a \"${binariesFolderPath}/Haveno-${appVersion}.exe\"\"")
        } else if (Os.isFamily(Os.FAMILY_MAC)) {
            // See https://docs.oracle.com/en/java/javase/14/jpackage/override-jpackage-resources.html
            // for details of "--resource-dir"

            String macOpts = new String(
                    " --resource-dir \"${project(':desktop').projectDir}/package/macosx\""
            )

            // Env variable can be set by calling "export BISQ_PACKAGE_SIGNING_IDENTITY='Some value'"
            // See "man codesign" for details about the expected signing identity
            String envVariableSigningID = "$System.env.BISQ_PACKAGE_SIGNING_IDENTITY"
            println "Environment variable BISQ_PACKAGE_SIGNING_IDENTITY is: ${envVariableSigningID}"
            ant.input(message: "Sign the app using the above signing identity? (y=yes, n=no)",
                    addproperty: "macos-sign-check",
                    validargs: "y,n")
            if (ant.properties['macos-sign-check'] == 'y') {
                // Create a temp folder to extract the macos-specific dylibs that need to be signed
                File tempDylibFolderPath = new File(tempRootDir, "dylibs-to-sign")
                tempDylibFolderPath.mkdirs()

                // Dylibs relevant for signing (paths relative to the tempDylibFolderPath)
                String dylibsToSign = new String(
                        " libjavafx_iio.dylib" +
                                " libglass.dylib" +
                                " libjavafx_font.dylib" +
                                " libprism_common.dylib" +
                                " libprism_es2.dylib" +
                                " libdecora_sse.dylib" +
                                " libprism_sw.dylib" +
                                " META-INF/native/libio_grpc_netty_shaded_netty_tcnative_osx_x86_64.jnilib"
                )

                // macOS step 1: Sign dylibs and replace them in the shadow jar
                // Extract dylibss for signing
                executeCmd("cd ${tempDylibFolderPath} &&" +
                        " jar xf ${fatJarFolderPath}/${mainJarName}" +
                        dylibsToSign)
                // Sign them
                executeCmd("cd ${tempDylibFolderPath} &&" +
                        " codesign -vvv --options runtime --deep --force --sign \"${envVariableSigningID}\"" +
                        dylibsToSign)

                // Verify signature
                executeCmd("cd ${tempDylibFolderPath} &&" +
                        " codesign -vvv --deep --strict " + dylibsToSign)

                // Replace unsigned files in jar file
                executeCmd("cd ${tempDylibFolderPath} &&" +
                        " jar uf ${fatJarFolderPath}/${mainJarName}" +
                        dylibsToSign)

                // macOS step 2: Build app-image using the shadow jar above (containing signed dylibs)
                // NOTE: licensing file cannot be added at this point only when creating the dmg later
                executeCmd(jPackageFilePath +
                        commonOpts +
                        macOpts +
                        " --type app-image")

                // macOS step 3: Sign app (hardended runtime)
                File havenoAppImageFullPath = new File(binariesFolderPath, "Haveno.app")
                executeCmd("codesign" +
                        " --sign \"${envVariableSigningID}\"" +
                        " --options runtime" +
                        " --entitlements '${project(':desktop').projectDir}/package/macosx/macos.entitlements'" +
                        " --force" +
                        " --verbose" +
                        " ${havenoAppImageFullPath}/Contents/runtime/Contents/MacOS/libjli.dylib")
                executeCmd("codesign" +
                        " --sign \"${envVariableSigningID}\"" +
                        " --options runtime" +
                        " --entitlements '${project(':desktop').projectDir}/package/macosx/macos.entitlements'" +
                        " --force" +
                        " --verbose" +
                        " ${havenoAppImageFullPath}/Contents/MacOS/Haveno")
                executeCmd("codesign" +
                        " --sign \"${envVariableSigningID}\"" +
                        " --options runtime" +
                        " --entitlements '${project(':desktop').projectDir}/package/macosx/macos.entitlements'" +
                        " --force" +
                        " --verbose" +
                        " ${havenoAppImageFullPath}")

                // macOS step 4: Package the app-image into a dmg bundle
                executeCmd(jPackageFilePath +
                        " --dest \"${binariesFolderPath}\"" +
                        " --name ${appNameAndVendor}" +
                        " --description \"${appDescription}\"" +
                        " --app-version ${appVersion}" +
                        " --copyright \"${appCopyright}\"" +
                        " --vendor ${appNameAndVendor}" +
                        " --temp \"${jpackageTempDir}\"" +
                        " --app-image ${havenoAppImageFullPath}" +
                        " --mac-sign" +
                        macOpts +
                        " --type dmg")

                // macOS step 5: Delete unused app image
                delete(havenoAppImageFullPath)

                // macOS step 6: Sign dmg bundle
                executeCmd("codesign" +
                        " --sign \"${envVariableSigningID}\"" +
                        " --options runtime" +
                        " --entitlements '${project(':desktop').projectDir}/package/macosx/macos.entitlements'" +
                        " -vvvv" +
                        " --deep" +
                        " '${binariesFolderPath}/Haveno-${appVersion}.dmg'")

                // macOS step 7: Upload for notarization
                // See https://developer.apple.com/documentation/xcode/notarizing_macos_software_before_distribution/customizing_the_notarization_workflow#3087734
                String envVariableAcUsername = "$System.env.BISQ_PACKAGE_NOTARIZATION_AC_USERNAME"
                String envVariableAscProvider = "$System.env.BISQ_PACKAGE_NOTARIZATION_ASC_PROVIDER"
                // e.g. network.bisq.CAT is used when binaries are built by @ripcurlx
                String envVariablePrimaryBundleId = "$System.env.BISQ_PRIMARY_BUNDLE_ID"
                def uploadForNotarizationOutput = executeCmd("xcrun altool --notarize-app" +
                        " --primary-bundle-id '${envVariablePrimaryBundleId}'" +
                        " --username '${envVariableAcUsername}'" +
                        " --password '@keychain:AC_PASSWORD'" +
                        " --asc-provider '${envVariableAscProvider}'" +
                        " --file '${binariesFolderPath}/Haveno-${appVersion}.dmg'")
                // Response:
                //  No errors uploading '[PATH_TO_BISQ_REPO]/bisq/desktop/build/temp-620637000/binaries/Haveno-1.1.1.dmg'.
                //  RequestUUID = ea8bba77-97b7-4c15-a53f-8bbccf627190
                def requestUUID = uploadForNotarizationOutput.split('RequestUUID = ')[1].trim()
                println "Extracted RequestUUID: " + requestUUID

                // Every 1 minute, check the status
                def notarizationEndedInSuccess = false
                def notarizationEndedInFailure = false
                while (!(notarizationEndedInSuccess || notarizationEndedInFailure)) {
                    println "Current time is:"
                    executeCmd('date')
                    println "Waiting for 1 minute..."
                    sleep(1 * 60 * 1000)

                    println "Checking notarization status"

                    def checkNotarizationStatusOutput = executeCmd("xcrun altool --notarization-info" +
                            " '${requestUUID}'" +
                            " --username '${envVariableAcUsername}'" +
                            " --password '@keychain:AC_PASSWORD'")

                    notarizationEndedInSuccess = checkNotarizationStatusOutput.contains('success')
                    notarizationEndedInFailure = checkNotarizationStatusOutput.contains('invalid')
                }

                if (notarizationEndedInFailure) {
                    ant.fail('Notarization failed, aborting')
                }

                if (notarizationEndedInSuccess) {
                    println "Notarization was successful"

                    // macOS step 8: Staple ticket on dmg
                    executeCmd("xcrun stapler staple" +
                            " '${binariesFolderPath}/Haveno-${appVersion}.dmg'")
                }

            } else {
                // If user didn't confirm the optional signing step, then generate a plain non-signed dmg
                executeCmd(jPackageFilePath + commonOpts + macOpts + " --type dmg")
            }
        } else {
            String linuxOpts = new String(
                    " --icon ${project(':desktop').projectDir}/package/linux/icon.png" +

                            // This defines the first part of the resulting packages (the application name)
                            // deb requires lowercase letters, therefore the application name is written in lowercase
                            " --linux-package-name bisq" +

                            // This represents the linux package version (revision)
                            // By convention, this is part of the deb/rpm package names, in addition to the software version
                            " --linux-app-release 1" +

                            " --linux-menu-group Network" +
                            " --linux-shortcut"
            )

            // Package deb
            executeCmd(jPackageFilePath + commonOpts + linuxOpts +
                    " --linux-deb-maintainer noreply@bisq.network" +
                    " --type deb")

            // Clean jpackage temp folder, needs to be empty for the next packaging step (rpm)
            jpackageTempDir.deleteDir()
            jpackageTempDir.mkdirs()

            // Package rpm
            executeCmd(jPackageFilePath + commonOpts + linuxOpts +
                    " --linux-rpm-license-type AGPLv3" + // https://fedoraproject.org/wiki/Licensing:Main?rd=Licensing#Good_Licenses
                    " --type rpm")
        }

        // Env variable can be set by calling "export BISQ_SHARED_FOLDER='Some value'"
        // This is to copy the final binary/ies to a shared folder for further processing if a VM is used.
        String envVariableSharedFolder = "$System.env.BISQ_SHARED_FOLDER"
        println "Environment variable BISQ_SHARED_FOLDER is: ${envVariableSharedFolder}"
        ant.input(message: "Copy the created binary to a shared folder? (y=yes, n=no)",
                addproperty: "copy-to-shared-folder",
                validargs: "y,n")
        if (ant.properties['copy-to-shared-folder'] == 'y') {
            copy {
                from binariesFolderPath
                into envVariableSharedFolder
            }
            executeCmd("open " + envVariableSharedFolder)
        }

        println "The binaries are ready:"
        binariesFolderPath.traverse {
            println it.path
        }
    }
}

def executeCmd(String cmd) {
    String shell
    String shellArg
    if (Os.isFamily(Os.FAMILY_WINDOWS)) {
        shell = 'cmd'
        shellArg = '/c'
    } else {
        shell = 'bash'
        shellArg = '-c'
    }

    println "Executing command:\n${cmd}\n"
    // See "Executing External Processes" section of
    // http://docs.groovy-lang.org/next/html/documentation/
    def commands = [shell, shellArg, cmd]
    def process = commands.execute(null, project.rootDir)
    def result
    if (process.waitFor() == 0) {
        result = process.text
        println "Command output (stdout):\n${result}"
    } else {
        result = process.err.text
        println "Command output (stderr):\n${result}"
    }
    return result
}