/* eslint-disable jsdoc/no-types */

const {spawnSync} = require('node:child_process');
const fs = require('node:fs');
const {join, resolve} = require('node:path');
const process = require('node:process');

const {flipFuses, FuseVersion, FuseV1Options} = require('@electron/fuses');
// Note: Not listed as a dependency because this is tied to electron and we take whatever we get here.
// eslint-disable-next-line import/no-extraneous-dependencies
const {GotDownloader} = require('@electron/get/dist/cjs/GotDownloader');
const packager = require('@electron/packager');
const {populateIgnoredPaths} = require('@electron/packager/dist/copy-filter');
const debug = require('debug');
const fsExtra = require('fs-extra');

const baseConfig = import('../config/base.js');

const log = debug('dist-electron');

/**
 * Error handling.
 */
function fail(errormsg) {
    console.error(errormsg);
    process.exit(1);
}

function allow(directory, pattern) {
    return (path) => {
        if (path === directory) {
            // It is the path itself
            // -> Continue walking recursively
            return 'allow';
        }

        if (path.startsWith(directory)) {
            // It is within path
            // -> Continue walking recursively if allowed by the pattern
            if (path.replace(directory, '').match(pattern)) {
                return 'allow';
            }
            return 'continue';
        }

        const parts = directory.split('/');
        for (const index of parts.keys()) {
            const sub = parts.slice(0, index + 1).join('/');
            if (path === sub) {
                // It is a sub-path of path
                // -> Continue walking recursively
                return 'allow';
            }

            if (!path.startsWith(`${sub}/`)) {
                // It is either not a sub-path of path or a file not within path
                // -> Stop walking recursively
                return 'continue';
            }
        }

        // It is not within path
        // -> Stop walking recursively
        return 'continue';
    };
}

/**
 * Set electron fuses to disable certain features in production builds, see
 * https://www.electronjs.org/docs/latest/tutorial/fuses
 */
async function setElectronFuses(binaryPath, isPlaywrightTestBuild = false) {
    console.log(`Setting electron fuses for ${binaryPath}`);
    await flipFuses(binaryPath, {
        version: FuseVersion.V1,
        // See: https://github.com/electron/fuses?tab=readme-ov-file#new-fuses.
        strictlyRequireAllFuses: true,
        resetAdHocDarwinSignature: process.platform === 'darwin' && process.arch === 'arm64',
        // Disable ELECTRON_RUN_AS_NODE
        [FuseV1Options.RunAsNode]: false,
        // Enable cookie encryption
        // Note: Threema doesn't set any cookies, so this has no advantage. On the other hand, it
        // causes a keychain permission request to appear, which we don't want if it has no benefit.
        [FuseV1Options.EnableCookieEncryption]: false,
        // Disable the NODE_OPTIONS environment variable
        [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
        // Disable the --inspect and --inspect-brk family of CLI options
        [FuseV1Options.EnableNodeCliInspectArguments]: isPlaywrightTestBuild,
        // Enable validation of the app.asar archive on macOS
        [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
        // Enforce that Electron will only load your app from "app.asar" instead of its normal search paths
        [FuseV1Options.OnlyLoadAppFromAsar]: true,
        // Load V8 Snapshot from `browser_v8_context_snapshot.bin` for the browser process
        // Note: Threema seems to crash on launch when setting this to true.
        [FuseV1Options.LoadBrowserProcessSpecificV8Snapshot]: false,
        // Changes whether pages loaded from the file:// protocol are given privileges beyond what
        // they would receive in a traditional web browser.
        [FuseV1Options.GrantFileProtocolExtraPrivileges]: false,
    });
}

/**
 * A custom downloader for @electron/get that logs all requests.
 */
const LoggingDownloader = {
    download: (url, targetFilePath, options) => {
        console.warn(
            `dist-electron.cjs: @electron/get is downloading artifact: url=${url}, path=${targetFilePath}`,
        );
        return new GotDownloader().download(url, targetFilePath, options);
    },
};

/**
 * Build and add an extra binary to the bundle. Note: The given path must contain a valid Rust
 * project.
 *
 * @param {string[]} basePath The base path of the extra binary's Rust project.
 * @param {*} outputPath The output path to copy the extra binary to.
 * @param {string} outputName The name of the output binary without extension.
 * @param {*} flavor The app flavor.
 * @param {typeof import("../config/base")} config Base config and utils.
 * @param {string=} outputNameOverride Override for `outputName` when the binary is copied to the
 *   destination bundle.
 * @param {boolean=false} isPrivilegedHelper Whether the extra binary is a privileged helper.
 */
function buildAndBundleExtraBinary(
    basePath,
    outputPath,
    outputName,
    flavor,
    config,
    outputNameOverride,
    isPrivilegedHelper = false,
) {
    // Build binary.
    console.log(`🦀 Building extra binary ${outputName} for ${flavor} through cargo`);
    const buildResult = spawnSync('cargo', ['build', `--release`], {
        cwd: resolve(...basePath),
        encoding: 'utf8',
        shell: false,
        stdio: [null, 1, 2],
        env: {
            ...process.env,
            THREEMA_BUILD_FLAVOR: flavor,
        },
    });
    if (buildResult.status !== 0) {
        console.warn(buildResult);
        fail(`Building extra binary failed, exit code ${buildResult.status}`);
    }
    if (!config.isBuildPlatform(process.platform)) {
        fail(
            `Extra binary build error: Platform "${process.platform}" is not a valid build target`,
        );
        return;
    }

    // Copy extra binary into application dir.
    const extraBinaryPath = resolve(
        ...basePath,
        'target',
        'release',
        config.determineExtraBinaryName(process.platform, outputName),
    );
    if (!fs.existsSync(extraBinaryPath)) {
        fail(
            `Could not find extra binary after building, path\n    ${extraBinaryPath}\n    does not exist`,
        );
    }

    const extraBinaryDestinationName =
        outputNameOverride === undefined
            ? config.determineExtraBinaryName(process.platform, outputName)
            : config.determineExtraBinaryName(process.platform, outputNameOverride);
    const extraBinaryDestinationPath =
        // eslint-disable-next-line no-nested-ternary
        process.platform === 'darwin'
            ? !isPrivilegedHelper
                ? join(
                      outputPath,
                      config.determineBinaryName(flavor, process.platform),
                      'Contents',
                      'MacOS',
                      extraBinaryDestinationName,
                  )
                : join(
                      outputPath,
                      config.determineBinaryName(flavor, process.platform),
                      'Contents',
                      'Library',
                      'LaunchServices',
                      extraBinaryDestinationName,
                  )
            : join(outputPath, extraBinaryDestinationName);

    fsExtra.copySync(extraBinaryPath, extraBinaryDestinationPath, {
        errorOnExist: true,
        dereference: false,
        preserveTimestamps: false,
    });
    console.log(`Copied extra binary ${extraBinaryDestinationName} to output directory`);

    // On macOS, patch bundle executable in `Info.plist`.
    if (process.platform === 'darwin') {
        const plistPath = join(
            outputPath,
            config.determineBinaryName(flavor, process.platform),
            'Contents',
            'Info.plist',
        );
        const plist = fs
            .readFileSync(plistPath, 'utf8')
            .replace(
                /<key>CFBundleExecutable<\/key>(?<whitespace>[\s]+)<string>ThreemaDesktop<\/string>/u,
                `<key>CFBundleExecutable</key>$<whitespace><string>${extraBinaryDestinationName}</string>`,
            );
        fs.writeFileSync(plistPath, plist, 'utf8');
    }
}

/**
 * Package the application for the given parameters.
 *
 * @param {*} variant The app variant to build.
 * @param {*} environment The environment to build for.
 * @param {*} isPlaywrightTestBuild Whether to build a test build for Playwright.
 * @param {typeof import("../config/base")} config Base config and utils.
 */
async function packageApp(variant, environment, isPlaywrightTestBuild, config) {
    const options = {};
    populateIgnoredPaths(options);

    // Determine app name
    const flavor = `${variant}-${environment}`;
    const appName = config.determineAppName(flavor);

    // Load package.json
    const pkg = JSON.parse(fs.readFileSync(resolve(__dirname, '..', 'package.json')));

    // Build allow-list
    const allowances = Object.entries(pkg.electron.dist.include || {}).map(([directory, pattern]) =>
        allow(directory, new RegExp(pattern, 'u')),
    );
    log('#Rules:', allowances.length);

    if (!config.isBuildPlatform(process.platform)) {
        fail(`Packaging error: Platform "${process.platform}" is not a valid build target`);
        return;
    }

    // DOC: https://electron.github.io/electron-packager/v16.0.0/interfaces/electronpackager.options.html#icon
    let icon;
    let platformSpecificOptions = {};
    switch (process.platform) {
        case 'darwin': {
            const appBundleId = config.determineAppRdn(flavor);
            icon = resolve(
                __dirname,
                '..',
                'packaging',
                'assets',
                'icons',
                'mac',
                `${variant}-${environment}.icns`,
            );

            // Important: Keep this complementary to the `SMAuthorizedClients` value defined in the
            // helper's `Info.plist`.
            let smPrivilegedExecutables;
            switch (variant) {
                case 'consumer':
                    smPrivilegedExecutables = {
                        'ch.threema.threema-desktop-helper': `identifier "ch.threema.threema-desktop-helper" and anchor apple generic and certificate leaf[subject.OU] = ${process.env.APPLE_TEAM_ID} and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */`,
                    };
                    break;

                case 'work':
                    smPrivilegedExecutables = {
                        'ch.threema.threema-work-desktop-helper': `identifier "ch.threema.threema-work-desktop-helper" and anchor apple generic and certificate leaf[subject.OU] = ${process.env.APPLE_TEAM_ID} and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */`,
                    };
                    break;

                default:
                    throw new Error(
                        `SMPrivilegedExecutables cannot be determined for unknown variant: "${variant}"`,
                    );
            }

            platformSpecificOptions = {
                // Will be used as CFBundleIdentifier in Info.plist
                appBundleId,
                // Will be used as LSApplicationCategoryType in Info.plist
                // DOC: https://electron.github.io/electron-packager/v16.0.0/interfaces/electronpackager.options.html#appcategorytype
                appCategoryType: 'public.app-category.social-networking',
                darwinDarkModeSupport: true,
                extendInfo: {
                    LSFileQuarantineEnabled: true,
                    SMPrivilegedExecutables: smPrivilegedExecutables,
                },
            };
            break;
        }
        case 'win32': {
            icon = resolve(
                __dirname,
                '..',
                'packaging',
                'assets',
                'icons',
                'win',
                `${variant}-${environment}.ico`,
            );
            platformSpecificOptions = {
                // DOC: https://electron.github.io/electron-packager/v16.0.0/interfaces/electronpackager.win32metadataoptions.html
                win32metadata: {
                    'CompanyName': pkg.author,
                    'ProductName': appName,
                    'InternalName': appName,
                    'FileDescription': pkg.description,
                    'requested-execution-level': 'asInvoker',
                },
            };
            break;
        }
        default:
            icon = undefined;
    }

    // Package
    // DOC: https://electron.github.io/electron-packager/v16.0.0/interfaces/electronpackager.options.html
    console.info('📦 Packaging application with electron-packager');
    const [outputPath] = await packager({
        appCopyright: '© Threema GmbH – Released under the AGPL-3.0 license',
        name: appName,
        executableName: 'ThreemaDesktop',
        dir: resolve(__dirname, '..'),
        out: resolve(__dirname, '..', 'build', 'electron', 'packaged'),

        asar: {
            // Exclude binary dependencies from the ASAR file, to avoid issues with code signing on
            // macOS.
            //
            // Context: If shared libraries are part of the ASAR file, they cannot be signed (only
            // the ASAR file as a whole is signed). macOS rejects the loading of unsigned native
            // libraries. By moving the libraries outside of the ASAR file, they can be signed.
            unpackDir: join('node_modules', '{better-sqlcipher,argon2}', `**`),
        },
        icon,
        extraResource: [
            resolve(__dirname, '..', 'src', 'public', 'res', 'icons', flavor, 'icon-512.png'),
            ...[16, 20, 24, 30, 32, 36, 40, 44, 48, 60, 64, 72, 80, 96, 256]
                .flatMap((size) => {
                    const base = `Square44x44Logo.targetsize-${size}`;
                    const modifiers = ['', '_altform-unplated', '_altform-lightunplated'];

                    return modifiers.map((mod) => `${base}${mod}.png`);
                })
                .concat(['StoreLogo.png', 'Square150x150Logo.png', 'Square44x44Logo.png'])
                .map((filename) =>
                    resolve(
                        __dirname,
                        '..',
                        'src',
                        'public',
                        'res',
                        'icons',
                        'msix',
                        flavor,
                        `${filename}`,
                    ),
                ),
        ],
        derefSymlinks: true,
        ignore: (path) => {
            // Deny: Default rules from electron-packager
            if (options.ignore.some((rule) => path.match(rule))) {
                log(' !', path);
                return true;
            }

            // Deny: dotfiles
            if (path.match(/\/\..+$/u)) {
                log(' !', path);
                return true;
            }

            // Go through rules list
            for (const command of allowances) {
                switch (command(path)) {
                    case 'allow':
                        // Allowed: Continue walking recursively
                        log(' +', path);
                        return false;
                    case 'deny':
                        // Denied: Stop walking recursively
                        log(' -', path);
                        return true;
                    case 'continue':
                        // No decision: Continue traversing ruleset
                        // log(' ?', path);
                        break;
                    default:
                        throw new Error('Unknown reply');
                }
            }

            // Default: Block
            log('  ', path);
            return true;
        },
        overwrite: true,
        prune: true,
        afterPrune: [
            (buildPath, electronVersion, platform, arch, done) => {
                // Remove empty directories within `node_modules`
                const nodeModulesPath = resolve(buildPath, 'node_modules');
                const dir = fs.opendirSync(nodeModulesPath);
                let entry;
                while ((entry = dir.readSync()) !== null) {
                    const modulePath = resolve(nodeModulesPath, entry.name);
                    if (entry.isDirectory() && fs.readdirSync(modulePath).length === 0) {
                        fs.rmSync(modulePath, {recursive: true});
                    }
                }
                dir.closeSync();

                // Done cleaning up!
                done();
            },
        ],
        download: {
            // Use checksums provided by the electron package
            checksums: JSON.parse(
                fs.readFileSync(
                    resolve(__dirname, '..', 'node_modules', 'electron', 'checksums.json'),
                ),
            ),
            // Override the downloader and log all download requests
            downloader: LoggingDownloader,
        },
        ...platformSpecificOptions,
    });

    // Set electron fuses
    const binaryPath = join(outputPath, config.determineBinaryName(flavor, process.platform));
    await setElectronFuses(binaryPath, isPlaywrightTestBuild);

    // Build launcher binary (unless the $SKIP_LAUNCHER_BINARY env var is set to "true").
    if (process.env.SKIP_LAUNCHER_BINARY !== 'true') {
        buildAndBundleExtraBinary(
            [__dirname, '..', 'src', 'rust', 'launcher'],
            outputPath,
            'ThreemaDesktopLauncher',
            flavor,
            config,
        );

        if (process.platform === 'darwin') {
            // On macOS, also bundle the privileged helper binary (required by the launcher binary
            // for updating).
            buildAndBundleExtraBinary(
                [__dirname, '..', 'src', 'rust', 'helper'],
                outputPath,
                'ThreemaDesktopHelper',
                flavor,
                config,
                `${config.determineAppRdn(flavor)}-helper`,
                true,
            );
        }
    }

    console.info(`Packaged: ${outputPath}`);
}

if (require.main === module) {
    // Parse arguments
    const [node, script, ...argv] = process.argv;
    if (argv.length < 2) {
        fail(`Usage: ${node} ${script} [--testing] (consumer|work) (sandbox|live)`);
    }

    // Handle optional flags
    let isPlaywrightTestBuild = false;
    if (argv[0] === '--testing') {
        isPlaywrightTestBuild = true;
        argv.shift(); // Remove flag
    }

    // Handle positional args
    const variant = argv[0];
    const environment = argv[1];

    baseConfig
        .then((config) => packageApp(variant, environment, isPlaywrightTestBuild, config))
        .catch((error) => {
            fail(error);
        });
}
