"use strict";

const fs = require("fs-extra");
const os = require("os");
const path = require("path");

const minimist = require("minimist");
const mustache = require("mustache");
const pkg = require("pkg");

const { spawnSync } = require("child_process");

const meta = require("../package.json");
const paths = require("./lib/paths");

const { author, homepage } = require("../../package.json");

const EXTENSION_ID = "fx_cast@matt.tf";

// Command line args
const argv = minimist(process.argv.slice(2), {
    boolean: ["usePkg", "package", "skipNativeBuilds"],
    string: ["arch", "packageType"],
    default: {
        arch: os.arch(),
        package: false,
        // Linux package type (deb/rpm)
        packageType: "deb",
        skipNativeBuilds: false
    }
});

const supportedTargets = [
    "win-x64",
    "win-x86",
    "macos-x64",
    "macos-arm64",
    "linux-x64"
];

const supportedPlatforms = [];
const supportedArchs = [];

for (const target of supportedTargets) {
    const [platform, arch] = target.split("-");

    supportedPlatforms.push(platform);
    supportedArchs.push(arch);
}

if (!supportedPlatforms.includes(paths.pkgPlatformMap[process.platform])) {
    console.error("Unsupported target platform");
    process.exit(1);
}
if (!supportedArchs.includes(argv.arch)) {
    console.error("Unsupported target arch");
    process.exit(1);
}

const ROOT_PATH = path.join(__dirname, "..");
const BUILD_PATH = path.join(ROOT_PATH, "build");

const spawnOptions = {
    shell: true,
    stdio: [process.stdin, process.stdout, process.stderr]
};

/**
 * Shouldn't exist, but cleanup and re-create any existing
 * build directories, just in case.
 */
fs.removeSync(BUILD_PATH);
fs.removeSync(paths.DIST_PATH, { recursive: true });
fs.ensureDirSync(BUILD_PATH);
fs.ensureDirSync(paths.DIST_PATH, { recursive: true });

const MDNS_BINDING_PATH = path.join(
    __dirname,
    "../node_modules/mdns/build/Release/"
);
const MDNS_BINDING_NAME = "dns_sd_bindings.node";

async function build() {
    // Run tsc
    spawnSync(
        `tsc --project ${ROOT_PATH} \
             --outDir ${BUILD_PATH}`,
        spawnOptions
    );

    /**
     * Native app manifest
     * https://mdn.io/Native_manifests#Native_messaging_manifests
     */
    const manifest = {
        name: meta.__applicationName,
        description: "",
        type: "stdio",
        allowed_extensions: [EXTENSION_ID]
    };

    /**
     * If packaging, add the installed executable path, otherwise
     * add the path to the built executable in the dist folder.
     */
    if (argv.package || argv.usePkg) {
        // Need a minimal package.json for pkg.
        const pkgManifest = {
            bin: "main.js",
            pkg: {
                /**
                 * Workaround for pkg asset detection
                 * https://github.com/thibauts/node-castv2/issues/46
                 */
                assets: "../../node_modules/castv2/lib/cast_channel.proto"
            }
        };

        const executableName = paths.getExecutableName(process.platform);
        const executablePath = paths.getExecutablePath(
            process.platform,
            argv.arch
        );

        // Write pkg manifest
        fs.writeFileSync(
            path.join(BUILD_PATH, "src/package.json"),
            JSON.stringify(pkgManifest)
        );

        // Run pkg to create a single executable
        await pkg.exec([
            path.join(BUILD_PATH, "src"),
            "--target",
            `node16-${paths.pkgPlatformMap[process.platform]}-${argv.arch}`,
            "--output",
            path.join(BUILD_PATH, executableName)
        ]);

        fs.copySync(
            path.join(MDNS_BINDING_PATH, MDNS_BINDING_NAME),
            path.join(BUILD_PATH, MDNS_BINDING_NAME)
        );

        fs.removeSync(path.join(BUILD_PATH, "src"));

        manifest.path =
            !argv.package && argv.usePkg
                ? path.join(paths.DIST_PATH, executableName)
                : path.join(executablePath, executableName);
    } else {
        let launcherPath = path.join(
            BUILD_PATH,
            meta.__applicationExecutableName
        );
        const modulesDir = path.join(ROOT_PATH, "node_modules");

        // Write launcher script
        switch (process.platform) {
            case "win32": {
                launcherPath += ".bat";
                fs.writeFileSync(
                    launcherPath,
                    `@echo off
setlocal
    set NODE_PATH=${modulesDir}
    node %~dp0src\\main.js --__name %~n0%~x0 %*
endlocal
`
                );
                break;
            }

            case "linux":
            case "darwin": {
                launcherPath += ".sh";
                fs.writeFileSync(
                    launcherPath,
                    `#!/usr/bin/env sh
NODE_PATH="${modulesDir}" node $(dirname $0)/src/main.js --__name $(basename $0) "$@"
`
                );
                break;
            }
        }

        manifest.path = path.join(paths.DIST_PATH, path.basename(launcherPath));
    }

    // Write app manifest
    fs.writeFileSync(
        path.join(BUILD_PATH, paths.MANIFEST_NAME),
        JSON.stringify(manifest, null, 4)
    );

    // Ensure file permissions are correct
    for (const file of fs.readdirSync(BUILD_PATH)) {
        fs.chmodSync(path.resolve(BUILD_PATH, file), 0o755);
    }

    /**
     * If packaging, create an installer package and move it to
     * dist, otherwise move the built executable and app manifest
     * to dist.
     */
    if (argv.package) {
        const installerName = await packageApp(process.platform, argv.arch);
        if (installerName) {
            // Move installer to dist
            fs.moveSync(
                path.join(BUILD_PATH, installerName),
                path.join(paths.DIST_PATH, path.basename(installerName)),
                { overwrite: true }
            );
        }
    } else {
        // Move tsc output and launcher to dist
        fs.moveSync(BUILD_PATH, paths.DIST_PATH, { overwrite: true });
    }

    // Remove build directory
    fs.removeSync(BUILD_PATH);
}

/**
 * Takes a platform and returns the path of the created
 * installer package.
 */
async function packageApp(platform, arch) {
    const packageFunctionArgs = [
        arch,
        // platformExecutableName
        paths.getExecutableName(platform, arch),
        // platformExecutablePath
        paths.getExecutablePath(platform, arch),
        // platformManifestPath
        paths.getManifestPath(platform, arch, argv.packageType)
    ];

    switch (platform) {
        case "win32":
            return packageWin32(...packageFunctionArgs);
        case "darwin":
            return packageDarwin(...packageFunctionArgs);

        case "linux": {
            switch (argv.packageType) {
                case "deb":
                    return packageLinuxDeb(...packageFunctionArgs);
                case "rpm":
                    return packageLinuxRpm(...packageFunctionArgs);
            }

            break;
        }

        default: {
            console.error("Unsupported target platform");
            process.exit(1);
        }
    }
}

/**
 * Builds a macOS Installer package.
 *
 * Creates a root directory with the installed file system
 * structure for package files, bundles the postinstall
 * script (packaging/mac/scripts/postinstall), then creates
 * a component package.
 * Distribution package is built from the component package
 * and distribution file (packaging/mac/distribution.xml).
 *
 * Requires the pkgbuild and productbuild command line
 * utilities. Only possible on macOS.
 */
function packageDarwin(
    arch,
    platformExecutableName,
    platformExecutablePath,
    platformManifestPath
) {
    const outputName = `${meta.__applicationName}-${meta.__applicationVersion}-${arch}.pkg`;
    const componentName = `${meta.__applicationName}_component.pkg`;

    const packagingDir = path.join(__dirname, "../packaging/mac/");
    const packagingOutputDir = path.join(BUILD_PATH, "packaging");

    // Create pkgbuild root
    const rootPath = path.join(BUILD_PATH, "root");
    const rootExecutablePath = path.join(rootPath, platformExecutablePath);
    const rootManifestPath = path.join(rootPath, platformManifestPath);

    // Create install locations
    fs.ensureDirSync(rootExecutablePath, { recursive: true });
    fs.ensureDirSync(rootManifestPath, { recursive: true });

    // Move files to root
    fs.moveSync(
        path.join(BUILD_PATH, platformExecutableName),
        path.join(rootExecutablePath, platformExecutableName)
    );
    fs.moveSync(
        path.join(BUILD_PATH, MDNS_BINDING_NAME),
        path.join(rootExecutablePath, MDNS_BINDING_NAME)
    );
    fs.moveSync(
        path.join(BUILD_PATH, paths.MANIFEST_NAME),
        path.join(rootManifestPath, paths.MANIFEST_NAME)
    );

    // Copy static files to be processed
    fs.copySync(packagingDir, packagingOutputDir);

    const view = {
        applicationName: meta.__applicationName,
        manifestName: paths.MANIFEST_NAME,
        componentName,
        packageId: `tf.matt.${meta.__applicationName}`,
        executablePath: platformExecutablePath,
        manifestPath: platformManifestPath
    };

    // Template paths
    const templatePaths = [
        path.join(packagingOutputDir, "scripts/postinstall"),
        path.join(packagingOutputDir, "distribution.xml")
    ];

    // Do templating on static files
    for (const templatePath of templatePaths) {
        const templateContent = fs.readFileSync(templatePath).toString();
        fs.writeFileSync(templatePath, mustache.render(templateContent, view));
    }

    // Build component package
    spawnSync(
        `pkgbuild --root ${rootPath} \
                  --identifier "tf.matt.${meta.__applicationName}" \
                  --version "${meta.__applicationVersion}" \
                  --scripts ${path.join(packagingOutputDir, "scripts")} \
                  ${path.join(BUILD_PATH, componentName)}`,
        spawnOptions
    );

    // Distribution XML file
    const distFilePath = path.join(packagingOutputDir, "distribution.xml");

    // Build installer package
    spawnSync(
        `productbuild --distribution ${distFilePath} \
                      --package-path ${BUILD_PATH} \
                      ${path.join(BUILD_PATH, outputName)}`,
        spawnOptions
    );

    return outputName;
}

/**
 * Builds a DEB package for Debian, Ubuntu, Mint, etc...
 *
 * Creates a root directory with the installed file system
 * structure for package files, copies control file
 * (packaging/linux/deb/DEBIAN/control) to root, then builds
 * package from root.
 * Requires the dpkg-deb command line utility.
 */
function packageLinuxDeb(
    arch,
    platformExecutableName,
    platformExecutablePath,
    platformManifestPath
) {
    const outputName = `${meta.__applicationName}-${meta.__applicationVersion}-${arch}.deb`;

    // Create root
    const rootPath = path.join(BUILD_PATH, "root");
    const rootExecutablePath = path.join(rootPath, platformExecutablePath);
    const rootManifestPath = path.join(rootPath, platformManifestPath);

    fs.ensureDirSync(rootExecutablePath, { recursive: true });
    fs.ensureDirSync(rootManifestPath, { recursive: true });

    // Move files to root
    fs.moveSync(
        path.join(BUILD_PATH, platformExecutableName),
        path.join(rootExecutablePath, platformExecutableName)
    );
    fs.moveSync(
        path.join(BUILD_PATH, MDNS_BINDING_NAME),
        path.join(rootExecutablePath, MDNS_BINDING_NAME)
    );
    fs.moveSync(
        path.join(BUILD_PATH, paths.MANIFEST_NAME),
        path.join(rootManifestPath, paths.MANIFEST_NAME)
    );

    const controlDir = path.join(__dirname, "../packaging/linux/deb/DEBIAN/");
    const controlOutputDir = path.join(rootPath, path.basename(controlDir));
    const controlFilePath = path.join(controlOutputDir, "control");

    // Copy package info to root
    fs.copySync(controlDir, controlOutputDir);

    const view = {
        // Debian package names can't contain underscores
        packageName: meta.__applicationName.replace(/_/g, "-"),
        applicationName: meta.__applicationName,
        applicationVersion: meta.__applicationVersion,
        author
    };

    // Do templating on control file
    fs.writeFileSync(
        controlFilePath,
        mustache.render(fs.readFileSync(controlFilePath).toString(), view)
    );

    // Build .deb package
    spawnSync(
        `dpkg-deb --build ${rootPath} \
                          ${path.join(BUILD_PATH, outputName)}`,
        spawnOptions
    );

    return outputName;
}

/**
 * Builds an RPM package for Fedora, openSUSE, etc...
 *
 * Templates and uses the spec file
 * (packaging/linux/rpm/package.spec) to build the package.
 * Requires the rpmbuild command line utility.
 */
function packageLinuxRpm(
    arch,
    platformExecutableName,
    platformExecutablePath,
    platformManifestPath
) {
    const outputName = `${meta.__applicationName}-${meta.__applicationVersion}-${arch}.rpm`;

    const specPath = path.join(
        __dirname,
        "../packaging/linux/rpm/package.spec"
    );

    const specOutputPath = path.join(BUILD_PATH, path.basename(specPath));

    const view = {
        packageName: meta.__applicationName,
        applicationName: meta.__applicationName,
        applicationVersion: meta.__applicationVersion,
        executablePath: platformExecutablePath,
        manifestPath: platformManifestPath,
        executableName: platformExecutableName,
        manifestName: paths.MANIFEST_NAME,
        bindingName: MDNS_BINDING_NAME
    };

    fs.writeFileSync(
        specOutputPath,
        mustache.render(fs.readFileSync(specPath).toString(), view)
    );

    const rpmArchMap = { x86: "i386", x64: "x86_64" };

    spawnSync(
        `rpmbuild -bb ${specOutputPath} \
                  --define "_distdir ${BUILD_PATH}" \
                  --define "_rpmdir ${BUILD_PATH}" \
                  --define "_rpmfilename ${outputName}" \
                  --target=${rpmArchMap[arch]}-linux`,
        spawnOptions
    );

    return outputName;
}

/**
 * Builds a Windows installer.
 *
 * Uses NSIS to create a GUI installer with an installer
 * script (packaging/win/installer.nsi). Requires the
 * makensis command line utility.
 */
function packageWin32(
    arch,
    platformExecutableName,
    platformExecutablePath,
    platformManifestPath
) {
    const outputName = `${meta.__applicationName}-${meta.__applicationVersion}-${arch}.exe`;

    const scriptPath = path.join(__dirname, "../packaging/win/installer.nsi");
    const scriptOutputPath = path.join(BUILD_PATH, path.basename(scriptPath));

    const view = {
        applicationName: meta.__applicationName,
        applicationVersion: meta.__applicationVersion,
        executableName: platformExecutableName,
        executablePath: platformExecutablePath,
        manifestName: paths.MANIFEST_NAME,
        bindingName: MDNS_BINDING_NAME,
        winRegistryKey: paths.REGISTRY_KEY,
        outputName,
        licensePath: paths.LICENSE_PATH,

        // Uninstaller keys
        registryPublisher: author,
        registryUrlInfoAbout: homepage
    };

    // Write templated script to build dir
    fs.writeFileSync(
        scriptOutputPath,
        mustache.render(fs.readFileSync(scriptPath).toString(), view)
    );

    spawnSync(`makensis /DARCH=${arch} ${scriptOutputPath}`, spawnOptions);

    return outputName;
}

build().catch(e => {
    console.log("Build failed", e);
    process.exit(1);
});
