/*
 * Copyright ©1998-2020 by Richard A. Wilkes. All rights reserved.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, version 2.0. If a copy of the MPL was not distributed with
 * this file, You can obtain one at http://mozilla.org/MPL/2.0/.
 *
 * This Source Code Form is "Incompatible With Secondary Licenses", as
 * defined by the Mozilla Public License, version 2.0.
 */

package bundler;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.lang.ProcessBuilder.Redirect;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitOption;
import java.nio.file.FileVisitResult;
import java.nio.file.FileVisitor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class Bundler {
    private static final String GCS_VERSION       = "4.19.1";
    private static       String JDK_MAJOR_VERSION = "14";
    private static final String ITEXT_VERSION     = "2.1.7";
    private static final String LOGGING_VERSION   = "1.2.0";
    private static final String FONTBOX_VERSION   = "2.0.17";
    private static final String PDFBOX_VERSION    = "2.0.17";
    private static final String LINUX             = "linux";
    private static final String MACOS             = "macos";
    private static final String WINDOWS           = "windows";
    private static final Path   DIST_DIR          = Paths.get("out", "dist");
    private static final Path   BUILD_DIR         = DIST_DIR.resolve("build");
    private static final Path   MODULE_DIR        = DIST_DIR.resolve("modules");
    private static final Path   EXTRA_DIR         = DIST_DIR.resolve("extra");
    private static final Path   I18N_DIR          = EXTRA_DIR.resolve("i18n");
    private static final Path   MANIFEST          = BUILD_DIR.resolve("com.trollworks.gcs.manifest");
    private static final Path   JRE               = BUILD_DIR.resolve("jre");
    private static final String YEARS             = "1998-" + DateTimeFormatter.ofPattern("yyyy").format(Instant.now().atZone(ZoneId.systemDefault()));
    private static final char[] HEX_DIGITS        = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
    private static       String OS;
    private static       Path   PKG;
    private static       Path   JPACKAGE_15;
    private static       String ICON_TYPE;

    /**
     * The main entry point for bundling GCS.
     *
     * @param args Arguments to the program.
     */
    public static void main(String[] args) {
        checkPlatform();

        boolean sign     = false;
        boolean notarize = false;
        for (String arg : args) {
            if (MACOS.equals(OS)) {
                if ("-s".equals(arg) || "--sign".equals(arg)) {
                    if (!sign) {
                        sign = true;
                        System.out.println("Signing enabled");
                    }
                    continue;
                }
                if ("-n".equals(arg) || "--notarize".equals(arg)) {
                    if (!notarize) {
                        notarize = true;
                        System.out.println("Notarization enabled");
                    }
                    continue;
                }
            }
            if ("-h".equals(arg) || "--help".equals(arg)) {
                System.out.println("-h, --help      This help");
                System.out.println("-n, --notarize  Enable notarization of the application (macOS only)");
                System.out.println("-s, --sign      Enable signing of the application (macOS only)");
                System.exit(0);
            }
            System.out.println("Ignoring argument: " + arg);
        }

        checkJDK();
        prepareDirs();
        compile();
        copyResources();
        createModules();
        extractLocalizationTemplate();
        packageApp(sign);

        if (notarize) {
            notarizeApp();
        }

        System.out.println("Finished!");
        System.out.println();
        System.out.println("Package can be found at:");
        System.out.println(PKG.toAbsolutePath().toString());
    }

    private static void checkPlatform() {
        String osName = System.getProperty("os.name");
        if (osName.startsWith("Mac")) {
            OS = MACOS;
            PKG = Paths.get("GCS-" + GCS_VERSION + ".dmg");
            ICON_TYPE = "icns";
        } else if (osName.startsWith("Win")) {
            OS = WINDOWS;
            PKG = Paths.get("GCS-" + GCS_VERSION + ".msi");
            ICON_TYPE = "ico";
        } else if (osName.startsWith("Linux")) {
            OS = LINUX;
            PKG = Paths.get("gcs-" + GCS_VERSION + "-1_amd64.deb");
            ICON_TYPE = "png";
        } else {
            System.err.println("Unsupported platform: " + osName);
            System.exit(1);
        }
    }

    private static void checkJDK() {
        ProcessBuilder builder = new ProcessBuilder("javac", "--version");
        builder.redirectOutput(Redirect.PIPE).redirectErrorStream(true);
        try {
            String  versionLine = "";
            Process process     = builder.start();
            try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
                String prefix = "javac ";
                String line;
                while ((line = in.readLine()) != null) {
                    if (line.startsWith(prefix)) {
                        versionLine = line.substring(prefix.length());
                    }
                }
            }
            if (!versionLine.startsWith(JDK_MAJOR_VERSION)) {
                System.err.println("JDK " + versionLine + " was found. JDK " + JDK_MAJOR_VERSION + " is required.");
                emitInstallJDKMessageAndExit();
            }
        } catch (IOException exception) {
            System.err.println("JDK " + JDK_MAJOR_VERSION + " is not installed!");
            emitInstallJDKMessageAndExit();
        }

        if (OS.equals(MACOS)) {
            boolean failed = false;
            Path    dir    = Paths.get(System.getProperty("user.home", "."), "jdk-15.jdk").toAbsolutePath();
            JPACKAGE_15 = dir.resolve(Paths.get("Contents", "Home", "bin", "jpackage"));
            builder = new ProcessBuilder(JPACKAGE_15.toString(), "--version");
            builder.redirectOutput(Redirect.PIPE).redirectErrorStream(true);
            try {
                String  versionLine = "";
                Process process     = builder.start();
                try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
                    String prefix = "15";
                    String line;
                    while ((line = in.readLine()) != null) {
                        if (line.startsWith(prefix)) {
                            versionLine = line;
                        }
                    }
                }
                if (!versionLine.startsWith("15")) {
                    failed = true;
                }
            } catch (IOException exception) {
                failed = true;
            }
            if (failed) {
                System.err.println("jpackage 15 is not available!");
                System.err.println("Unpack JDK 15 from http://jdk.java.net/15/ into " + " and try again.");
                System.exit(1);
            }
        }
    }

    private static void emitInstallJDKMessageAndExit() {
        System.err.println("Install JDK " + JDK_MAJOR_VERSION + " from http://jdk.java.net/" + JDK_MAJOR_VERSION + "/ and try again.");
        System.exit(1);
    }

    private static void prepareDirs() {
        System.out.print("Removing any previous build data... ");
        System.out.flush();
        long timing = System.nanoTime();
        try {
            if (Files.exists(DIST_DIR)) {
                Files.walkFileTree(DIST_DIR, new RecursiveDirectoryRemover());
            }
            Files.createDirectories(DIST_DIR);
            Files.createDirectories(BUILD_DIR);
            Files.createDirectories(MODULE_DIR);
            Files.createDirectories(EXTRA_DIR);
            Files.createDirectories(I18N_DIR);
            Files.deleteIfExists(PKG);
        } catch (IOException exception) {
            System.out.println();
            exception.printStackTrace(System.err);
            System.exit(1);
        }
        showTiming(timing);
    }

    private static void showTiming(long timing) {
        System.out.println(String.format("%,.3fs", Double.valueOf((System.nanoTime() - timing) / 1000000000.0)));
    }

    private static void compile() {
        System.out.print("Compiling... ");
        System.out.flush();
        long timing     = System.nanoTime();
        Path javacInput = BUILD_DIR.resolve("javac.input");
        try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(javacInput))) {
            out.println("-d");
            out.println(BUILD_DIR.toString());
            out.println("--release");
            out.println(JDK_MAJOR_VERSION);
            out.println("-encoding");
            out.println("UTF8");
            out.println("--module-source-path");
            out.printf(".%1$s*%1$ssrc%2$sthird_party%1$s*%1$ssrc\n", File.separator, File.pathSeparator);
            FileScanner.walk(Paths.get("."), (path) -> {
                if (path.getFileName().toString().endsWith(".java") && !path.startsWith(Paths.get(".", "bundler"))) {
                    out.println(path.toString());
                }
            });
        } catch (IOException exception) {
            System.out.println();
            exception.printStackTrace(System.err);
            System.exit(1);
        }
        runNoOutputCmd("javac", "@" + javacInput.toString());
        showTiming(timing);
    }

    private static void copyResources() {
        System.out.print("Copying resources... ");
        System.out.flush();
        long timing = System.nanoTime();
        copyResourceTree(Paths.get("com.trollworks.gcs", "resources"), BUILD_DIR.resolve("com.trollworks.gcs"));
        copyResourceTree(Paths.get("third_party", "org.apache.pdfbox", "resources"), BUILD_DIR.resolve("org.apache.pdfbox"));
        copyResourceTree(Paths.get("third_party", "org.apache.fontbox", "resources"), BUILD_DIR.resolve("org.apache.fontbox"));
        copyResourceTree(Paths.get("third_party", "com.lowagie.text", "resources"), BUILD_DIR.resolve("com.lowagie.text"));
        showTiming(timing);
    }

    private static void copyResourceTree(Path src, Path dst) {
        FileScanner.walk(src, (path) -> {
            Path target = dst.resolve(src.relativize(path));
            try {
                Files.createDirectories(target.getParent());
                try (InputStream in = Files.newInputStream(path)) {
                    try (OutputStream out = Files.newOutputStream(target)) {
                        byte[] data = new byte[8192];
                        int    amt;
                        while ((amt = in.read(data)) != -1) {
                            out.write(data, 0, amt);
                        }
                    }
                }
            } catch (IOException exception) {
                System.out.println();
                exception.printStackTrace(System.err);
                System.exit(1);
            }
        });
    }

    private static void createModules() {
        System.out.print("Creating modules... ");
        System.out.flush();
        long timing = System.nanoTime();
        createManifest();
        List<String> args = new ArrayList<>();
        args.add("jar");
        args.add("--create");
        args.add("--file");
        args.add(MODULE_DIR.resolve("com.trollworks.gcs-" + GCS_VERSION + ".jar").toString());
        args.add("--module-version");
        args.add(GCS_VERSION);
        args.add("--manifest");
        args.add(MANIFEST.toString());
        args.add("--main-class");
        args.add("com.trollworks.gcs.GCS");
        args.add("-C");
        args.add(BUILD_DIR.resolve("com.trollworks.gcs").toString());
        args.add(".");
        runNoOutputCmd(args);
        buildJar("com.lowagie.text", ITEXT_VERSION);
        buildJar("org.apache.commons.logging", LOGGING_VERSION);
        buildJar("org.apache.fontbox", FONTBOX_VERSION);
        buildJar("org.apache.pdfbox", PDFBOX_VERSION);
        showTiming(timing);
    }

    private static void createManifest() {
        try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(MANIFEST))) {
            out.println("Manifest-Version: 1.0");
            out.println("bundle-name: GCS");
            out.println("bundle-version: " + GCS_VERSION);
            out.println("bundle-license: Mozilla Public License 2.0");
            out.println("bundle-copyright-owner: Richard A. Wilkes");
            out.println("bundle-copyright-years: " + YEARS);
        } catch (IOException exception) {
            System.out.println();
            exception.printStackTrace(System.err);
            System.exit(1);
        }
    }

    private static void buildJar(String pkg, String version) {
        List<String> args = new ArrayList<>();
        args.add("jar");
        args.add("--create");
        args.add("--file");
        args.add(MODULE_DIR.resolve(pkg + "-" + version + ".jar").toString());
        args.add("--module-version");
        args.add(version);
        args.add("-C");
        args.add(BUILD_DIR.resolve(pkg).toString());
        args.add(".");
        runNoOutputCmd(args);
    }

    private static void extractLocalizationTemplate() {
        System.out.print("Extracting localization template... ");
        System.out.flush();
        long        timing = System.nanoTime();
        Set<String> keys   = new HashSet<>();
        try {
            Files.walk(Paths.get("com.trollworks.gcs", "src")).filter(path -> {
                String lower = path.getFileName().toString().toLowerCase();
                return lower.endsWith(".java") && !lower.endsWith("i18n.java") && Files.isRegularFile(path) && Files.isReadable(path);
            }).distinct().forEach(path -> {
                try {
                    Files.lines(path).forEachOrdered(line -> {
                        while (!line.isEmpty()) {
                            int i = line.indexOf("I18n.Text(");
                            if (i < 0) {
                                break;
                            }
                            int max = line.length();
                            i += 10;
                            while (i < max) {
                                char ch = line.charAt(i);
                                if (ch != ' ' && ch != '\t') {
                                    break;
                                }
                                i++;
                            }
                            if (i >= max || line.charAt(i) != '"') {
                                break;
                            }
                            i++;
                            line = processLine(keys, line.substring(i));
                        }
                    });
                } catch (IOException ioe) {
                    System.out.println();
                    ioe.printStackTrace(System.err);
                    System.exit(1);
                }
            });
            try (PrintStream out = new PrintStream(Files.newOutputStream(I18N_DIR.resolve("template.i18n")), true, StandardCharsets.UTF_8)) {
                out.println("# Generated on " + new Date());
                out.println("#");
                out.println("# This file consists of UTF-8 text. Do not save it as anything else.");
                out.println("#");
                out.println("# Key-value pairs are defined as one or more lines prefixed with 'k:' for the");
                out.println("# key, followed by one or more lines prefixed with 'v:' for the value. These");
                out.println("# prefixes are then followed by a quoted string, as generated by Text.quote().");
                out.println("# When two or more lines are present in a row, they will be concatenated");
                out.println("# together with an intervening \\n character.");
                out.println("#");
                out.println("# Do NOT modify the 'k' values. They are the values as seen in the code.");
                out.println("#");
                out.println("# Replace the 'v' values with the appropriate translation.");
                keys.stream().sorted().forEachOrdered(key -> {
                    out.println();
                    String quoted = quote(key);
                    if (quoted.length() < 77) {
                        out.println("k:" + quoted);
                        out.println("v:" + quoted);
                    } else {
                        String[] parts = key.split("\n", -1);
                        for (String part : parts) {
                            out.println("k:" + quote(part));
                        }
                        for (String part : parts) {
                            out.println("v:" + quote(part));
                        }
                    }
                });
            }
        } catch (Exception ex) {
            System.out.println();
            ex.printStackTrace(System.err);
            System.exit(1);
        }
        showTiming(timing);
    }

    private static String processLine(Set<String> keys, String in) {
        StringBuilder buffer       = new StringBuilder();
        int           len          = in.length();
        int           state        = 0;
        int           unicodeValue = 0;
        for (int i = 0; i < len; i++) {
            char ch = in.charAt(i);
            switch (state) {
            case 0: // Looking for end quote
                if (ch == '"') {
                    keys.add(buffer.toString());
                    return in.substring(i + 1);
                }
                if (ch == '\\') {
                    state = 1;
                    continue;
                }
                buffer.append(ch);
                break;
            case 1: // Processing escape sequence
                switch (ch) {
                case 't':
                    buffer.append('\t');
                    state = 0;
                    break;
                case 'b':
                    buffer.append('\b');
                    state = 0;
                    break;
                case 'n':
                    buffer.append('\n');
                    state = 0;
                    break;
                case 'r':
                    buffer.append('\r');
                    state = 0;
                    break;
                case '"':
                    buffer.append('"');
                    state = 0;
                    break;
                case '\\':
                    buffer.append('\\');
                    state = 0;
                    break;
                case 'u':
                    state = 2;
                    unicodeValue = 0;
                    break;
                default:
                    System.out.println();
                    new RuntimeException("invalid escape sequence").printStackTrace(System.err);
                    System.exit(1);
                }
                break;
            case 2: // Processing first digit of unicode escape sequence
            case 3: // Processing second digit of unicode escape sequence
            case 4: // Processing third digit of unicode escape sequence
            case 5: // Processing fourth digit of unicode escape sequence
                if (!isHexDigit(ch)) {
                    System.out.println();
                    new RuntimeException("invalid unicode escape sequence").printStackTrace(System.err);
                    System.exit(1);
                }
                unicodeValue *= 16;
                unicodeValue += hexDigitValue(ch);
                if (state == 5) {
                    state = 0;
                    buffer.append((char) unicodeValue);
                } else {
                    state++;
                }
                break;
            default:
                System.out.println();
                new RuntimeException("invalid state").printStackTrace(System.err);
                System.exit(1);
                break;
            }
        }
        return "";
    }

    private static boolean isHexDigit(char ch) {
        return ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f' || ch >= 'A' && ch <= 'F';
    }

    private static int hexDigitValue(char ch) {
        if (ch >= '0' && ch <= '9') {
            return ch - '0';
        }
        if (ch >= 'a' && ch <= 'f') {
            return 10 + ch - 'a';
        }
        if (ch >= 'A' && ch <= 'F') {
            return 10 + ch - 'A';
        }
        return 0;
    }

    private static String quote(String in) {
        StringBuilder buffer = new StringBuilder();
        int           length = in.length();
        buffer.append('"');
        for (int i = 0; i < length; i++) {
            char ch = in.charAt(i);
            if (ch == '"' || ch == '\\') {
                buffer.append('\\');
                buffer.append(ch);
            } else if (isPrintableChar(ch)) {
                buffer.append(ch);
            } else {
                switch (ch) {
                case '\b':
                    buffer.append("\\b");
                    break;
                case '\f':
                    buffer.append("\\f");
                    break;
                case '\n':
                    buffer.append("\\n");
                    break;
                case '\r':
                    buffer.append("\\r");
                    break;
                case '\t':
                    buffer.append("\\t");
                    break;
                default:
                    buffer.append("\\u");
                    buffer.append(HEX_DIGITS[ch >> 12 & 0xF]);
                    buffer.append(HEX_DIGITS[ch >> 8 & 0xF]);
                    buffer.append(HEX_DIGITS[ch >> 4 & 0xF]);
                    buffer.append(HEX_DIGITS[ch & 0xF]);
                    break;
                }
            }
        }
        buffer.append('"');
        return buffer.toString();
    }

    private static boolean isPrintableChar(char ch) {
        if (!Character.isISOControl(ch) && Character.isDefined(ch)) {
            try {
                Character.UnicodeBlock block = Character.UnicodeBlock.of(ch);
                return block != null && block != Character.UnicodeBlock.SPECIALS;
            } catch (Exception ex) {
                return false;
            }
        }
        return false;
    }

    private static void packageApp(boolean sign) {
        System.out.print("Packaging the application... ");
        System.out.flush();
        long         timing = System.nanoTime();
        List<String> args   = new ArrayList<>();
        args.add("jlink");
        args.add("--module-path");
        args.add(MODULE_DIR.toString());
        args.add("--output");
        args.add(JRE.toString());
        args.add("--compress=2");
        args.add("--no-header-files");
        args.add("--no-man-pages");
        args.add("--strip-debug");
        args.add("--strip-native-commands");
        args.add("--add-modules");
        args.add("com.trollworks.gcs");
        runNoOutputCmd("jlink", "--module-path", MODULE_DIR.toString(), "--output", JRE.toString(), "--compress=2", "--no-header-files", "--no-man-pages", "--strip-debug", "--strip-native-commands", "--add-modules", "com.trollworks.gcs");
        args.clear();
        if (OS.equals(MACOS)) {
            args.add(JPACKAGE_15.toString());
        } else {
            args.add("jpackage");
        }
        args.add("--module");
        args.add("com.trollworks.gcs/com.trollworks.gcs.GCS");
        args.add("--app-version");
        args.add(GCS_VERSION);
        args.add("--copyright");
        args.add("©" + YEARS + " by Richard A. Wilkes");
        args.add("--vendor");
        args.add("Richard A. Wilkes");
        args.add("--description");
        args.add("GCS (GURPS Character Sheet) is a stand-alone, interactive, character sheet editor that allows you to build characters for the GURPS 4th Edition roleplaying game system.");
        args.add("--license-file");
        args.add("LICENSE");
        args.add("--icon");
        args.add(Paths.get("artifacts", ICON_TYPE, "app." + ICON_TYPE).toString());
        for (String ext : new String[]{"adm", "adq", "eqm", "eqp", "gcs", "gct", "not", "skl", "spl"}) {
            args.add("--file-associations");
            args.add(Paths.get("artifacts", "file_associations", OS, ext + "_ext.properties").toString());
        }
        args.add("--input");
        args.add(EXTRA_DIR.toString());
        args.add("--runtime-image");
        args.add(JRE.toString());
        args.add("--java-options");
        args.add("-Dhttps.protocols=TLSv1.2,TLSv1.1,TLSv1");
        switch (OS) {
        case MACOS:
            args.add("--mac-package-name");
            args.add("GCS");
            args.add("--mac-package-identifier");
            args.add("com.trollworks.gcs");
            if (sign) {
                args.add("--mac-sign");
                args.add("--mac-signing-key-user-name");
                args.add("Richard Wilkes");
            }
            break;
        case LINUX:
            args.add("--linux-package-name");
            args.add("gcs");
            args.add("--linux-deb-maintainer");
            args.add("[email protected]");
            args.add("--linux-menu-group");
            args.add("Roleplaying");
            args.add("--linux-app-category");
            args.add("Roleplaying");
            args.add("--linux-rpm-license-type");
            args.add("MPLv2.0");
            args.add("--linux-shortcut");
            args.add("--linux-app-release");
            args.add("1");
            args.add("--linux-package-deps");
            args.add("");
            break;
        case WINDOWS:
            args.add("--java-options");
            args.add("-Dsun.java2d.dpiaware=false");
            args.add("--win-menu");
            args.add("--win-menu-group");
            args.add("Roleplaying");
            args.add("--win-shortcut");
            args.add("--type");
            args.add("msi");
            args.add("--win-dir-chooser");
            args.add("--win-upgrade-uuid");
            args.add("E71F99DA-AD84-4E6E-9bE7-4E65421752E1");
            Path propsFile = BUILD_DIR.resolve("console.properties");
            try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(propsFile))) {
                out.println("win-console=true");
            } catch (IOException exception) {
                System.out.println();
                exception.printStackTrace(System.err);
                System.exit(1);
            }
            args.add("--add-launcher");
            args.add("GCScmdline=" + propsFile.toString());
            break;
        }
        runNoOutputCmd(args);
        showTiming(timing);
    }

    private static void notarizeApp() {
        System.out.print("Notarizing the application... ");
        System.out.flush();
        long         timing = System.nanoTime();
        List<String> args   = new ArrayList<>();
        args.add("xcrun");
        args.add("altool");
        args.add("--notarize-app");
        args.add("--type");
        args.add("osx");
        args.add("--file");
        args.add(PKG.toAbsolutePath().toString());
        args.add("--primary-bundle-id");
        args.add("com.trollworks.gcs");
        args.add("--password");
        args.add("@keychain:gcs_app_pw");
        List<String> lines     = runCmd(args);
        String       requestID = null;
        boolean      noErrors  = false;
        for (String line : lines) {
            line = line.trim();
            if (line.startsWith("No errors uploading ")) {
                noErrors = true;
            } else if (line.startsWith("RequestUUID = ")) {
                String[] parts = line.split("=", 2);
                if (parts.length == 2) {
                    requestID = parts[1].trim();
                }
                if (noErrors) {
                    break;
                }
            }
        }
        if (!noErrors || requestID == null) {
            failWithLines("Unable to locate request ID from response. Response follows:", lines);
        }

        args.clear();
        args.add("xcrun");
        args.add("altool");
        args.add("--notarization-info");
        args.add(requestID);
        args.add("--password");
        args.add("@keychain:gcs_app_pw");
        boolean success = false;
        while (!success) {
            try {
                Thread.sleep(10000); // 10 seconds
            } catch (InterruptedException exception) {
                exception.printStackTrace();
            }
            lines = runCmd(args);
            for (String line : lines) {
                line = line.trim();
                if ("Status: invalid".equals(line)) {
                    failWithLines("Notarization failed. Response follows:", lines);
                }
                if ("Status: success".equals(line)) {
                    success = true;
                }
            }
            System.out.print(".");
            System.out.flush();
        }

        args.clear();
        args.add("xcrun");
        args.add("stapler");
        args.add("staple");
        args.add(PKG.toAbsolutePath().toString());
        success = false;
        for (String line : runCmd(args)) {
            line = line.trim();
            if ("The staple and validate action worked!".equals(line)) {
                success = true;
            }
        }
        if (!success) {
            failWithLines("Stapling failed. Response follows:", lines);
        }
        showTiming(timing);
    }

    private static void failWithLines(String msg, List<String> lines) {
        System.out.println();
        System.err.println(msg);
        System.err.println();
        for (String line : lines) {
            System.err.println(line);
        }
        System.exit(1);
    }

    private static List<String> runCmd(List<String> args) {
        List<String>   lines   = new ArrayList<>();
        ProcessBuilder builder = new ProcessBuilder(args);
        builder.redirectOutput(Redirect.PIPE).redirectErrorStream(true);
        try {
            boolean hadMsg  = false;
            Process process = builder.start();
            try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
                String line = in.readLine();
                while (line != null) {
                    lines.add(line);
                    line = in.readLine();
                }
            }
        } catch (IOException exception) {
            System.out.println();
            exception.printStackTrace(System.err);
            System.exit(1);
        }
        return lines;
    }

    private static void runNoOutputCmd(List<String> args) {
        runNoOutputCmd(args.toArray(new String[0]));
    }

    private static void runNoOutputCmd(String... args) {
        ProcessBuilder builder = new ProcessBuilder(args);
        builder.redirectOutput(Redirect.PIPE).redirectErrorStream(true);
        try {
            boolean hadMsg  = false;
            Process process = builder.start();
            try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
                String line = in.readLine();
                while (line != null) {
                    if (!line.startsWith("WARNING: Using incubator modules: jdk.incubator.jpackage")) {
                        if (!hadMsg) {
                            System.out.println();
                        }
                        System.err.println(line);
                        hadMsg = true;
                    }
                    line = in.readLine();
                }
            }
            if (hadMsg) {
                System.exit(1);
            }
        } catch (IOException exception) {
            System.out.println();
            exception.printStackTrace(System.err);
            System.exit(1);
        }
    }

    static class RecursiveDirectoryRemover implements FileVisitor<Path> {
        @Override
        public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
            return FileVisitResult.CONTINUE;
        }

        @Override
        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
            Files.delete(file);
            return FileVisitResult.CONTINUE;
        }

        @Override
        public FileVisitResult visitFileFailed(Path file, IOException exception) throws IOException {
            System.out.println();
            exception.printStackTrace(System.err);
            System.exit(1);
            return FileVisitResult.CONTINUE;
        }

        @Override
        public FileVisitResult postVisitDirectory(Path dir, IOException exception) throws IOException {
            if (exception != null) {
                System.out.println();
                exception.printStackTrace(System.err);
                System.exit(1);
            }
            if (!dir.equals(DIST_DIR)) {
                Files.delete(dir);
            }
            return FileVisitResult.CONTINUE;
        }
    }

    public interface Handler {
        void processFile(Path path) throws IOException;
    }

    static class FileScanner implements FileVisitor<Path> {
        private Path    mPath;
        private Handler mHandler;

        public static final void walk(Path path, Handler handler) {
            try {
                Files.walkFileTree(path, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, new FileScanner(path, handler));
            } catch (Exception exception) {
                System.out.println();
                exception.printStackTrace(System.err);
                System.exit(1);
            }
        }

        private FileScanner(Path path, Handler handler) {
            mPath = path;
            mHandler = handler;
        }

        private boolean shouldSkip(Path path) {
            return !mPath.equals(path) && path.getFileName().toString().startsWith(".");
        }

        @Override
        public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) throws IOException {
            if (shouldSkip(path)) {
                return FileVisitResult.SKIP_SUBTREE;
            }
            return FileVisitResult.CONTINUE;
        }

        @Override
        public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
            if (!shouldSkip(path)) {
                mHandler.processFile(path);
            }
            return FileVisitResult.CONTINUE;
        }

        @Override
        public FileVisitResult visitFileFailed(Path path, IOException exception) throws IOException {
            System.out.println();
            exception.printStackTrace(System.err);
            System.exit(1);
            return FileVisitResult.CONTINUE;
        }

        @Override
        public FileVisitResult postVisitDirectory(Path path, IOException exception) throws IOException {
            if (exception != null) {
                System.out.println();
                exception.printStackTrace(System.err);
                System.exit(1);
            }
            return FileVisitResult.CONTINUE;
        }
    }
}