// *****************************************************************************
// Copyright (C) 2021 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
// @ts-check
const fsx = require('fs-extra');
const { resolve } = require('path');
const { spawn, ChildProcess } = require('child_process');
const {
    analyzeTrace, backendSettled, ContributionCollector, delay, frontendSettled, githubReporting,
    isLCP, lcp, measureMulti, parseStopwatchLog, waitForFileStable
} = require('./common-performance');
const traceConfigTemplate = require('./electron-trace-config.json');
const { exit } = require('process');

const basePath = resolve(__dirname, '../..');
const profilesPath = resolve(__dirname, './profiles/');
const electronExample = resolve(basePath, 'examples/electron');
const theia = resolve(basePath, 'node_modules/.bin/theia');

let name = 'Electron Frontend Startup';
let folder = 'electron';
let runs = 10;
let workspace = resolve('./workspace');
let debugging = false;

(async () => {
    let defaultWorkspace = true;

    const yargs = require('yargs');
    const args = yargs(process.argv.slice(2)).option('name', {
        alias: 'n',
        desc: 'A name for the test suite',
        type: 'string',
        default: name
    }).option('folder', {
        alias: 'f',
        desc: 'Name of a folder within the "profiles" folder in which to collect trace logs',
        type: 'string',
        default: folder
    }).option('runs', {
        alias: 'r',
        desc: 'The number of times to run the test',
        type: 'number',
        default: runs
    }).option('workspace', {
        alias: 'w',
        desc: 'Path to a Theia workspace to open',
        type: 'string',
        default: workspace
    }).option('debug', {
        alias: 'X',
        desc: 'Whether to log debug information',
        boolean: true
    }).wrap(Math.min(120, yargs.terminalWidth())).argv;

    if (args.name) {
        name = args.name.toString();
    }
    if (args.folder) {
        folder = args.folder.toString();
    }
    if (args.workspace) {
        workspace = args.workspace.toString();
        if (resolve(workspace) !== workspace) {
            console.log('Workspace path must be an absolute path:', workspace);
            exit(1);
        }
        defaultWorkspace = false;
    }
    if (args.runs) {
        runs = parseInt(args.runs.toString());
    }
    debugging = args.debug;
    if (process.env.GITHUB_ACTIONS) {
        githubReporting.enabled = true;
    }

    // Verify that the application exists
    const indexHTML = resolve(electronExample, 'src-gen/frontend/index.html');
    if (!fsx.existsSync(indexHTML)) {
        console.error('Electron example app does not exist. Please build it before running this script.');
        process.exit(1);
    }

    if (defaultWorkspace) {
        // Ensure that it exists
        fsx.ensureDirSync(workspace);
    }

    await measurePerformance();
})();

async function measurePerformance() {
    fsx.emptyDirSync(resolve(profilesPath, folder));
    const traceConfigPath = resolve(profilesPath, folder, 'trace-config.json');

    /**
     * Generate trace config from the template.
     * @param {number} runNr
     * @returns {string} the output trace file path
     */
    const traceConfigGenerator = (runNr) => {
        const traceConfig = { ...traceConfigTemplate };
        const traceFilePath = resolve(profilesPath, folder, `${runNr}.json`);
        traceConfig.result_file = traceFilePath
        fsx.writeFileSync(traceConfigPath, JSON.stringify(traceConfig, undefined, 2), 'utf-8');
        return traceFilePath;
    };

    const exitHandler = (andExit = false) => {
        return () => {
            if (electron && !electron.killed) {
                process.kill(-electron.pid, 'SIGINT');
            }
            if (andExit) {
                process.exit();
            }
        }
    };

    // Be sure not to leave a detached Electron child process
    process.on('exit', exitHandler());
    process.on('SIGINT', exitHandler(true));
    process.on('SIGTERM', exitHandler(true));

    let electron;

    // Collects per-contribution timings from Stopwatch log lines observed on the Electron
    // child process streams across all runs. Both backend and frontend contributions are
    // observable here: Theia's frontend logger forwards console output to the backend over
    // RPC, so the backend process's stdout/stderr contains logs from both sides.
    const contributions = new ContributionCollector();

    const testScenario = async (runNr) => {
        const traceFile = traceConfigGenerator(runNr);
        electron = await launchElectron(traceConfigPath);

        // Capture Stopwatch log lines from the child process streams. Theia routes log
        // output through both stdout and stderr depending on log level, and the frontend
        // logger forwards its output to the backend over RPC, so both backend and frontend
        // Stopwatch lines appear here.
        let backendSettledSeconds;
        let frontendSettledSeconds;
        const lineBuffers = { stderr: '', stdout: '' };
        const consumeStream = (streamName, chunk) => {
            lineBuffers[streamName] += chunk;
            let newlineIndex;
            while ((newlineIndex = lineBuffers[streamName].indexOf('\n')) >= 0) {
                const line = lineBuffers[streamName].slice(0, newlineIndex);
                lineBuffers[streamName] = lineBuffers[streamName].slice(newlineIndex + 1);
                const parsed = parseStopwatchLog(line);
                if (!parsed) {
                    continue;
                }
                if (parsed.activity === backendSettled) {
                    backendSettledSeconds = parsed.secondsSinceStart;
                } else if (parsed.activity === frontendSettled) {
                    frontendSettledSeconds = parsed.secondsSinceStart;
                } else if (parsed.activity.startsWith('Backend ') || parsed.activity.startsWith('Frontend ')) {
                    contributions.record(parsed.activity, parsed.ms / 1000);
                }
            }
        };
        electron.stderr.on('data', data => {
            const chunk = data.toString();
            analyzeStderr(chunk);
            consumeStream('stderr', chunk);
        });
        electron.stdout.on('data', data => consumeStream('stdout', data.toString()));

        // Wait for Chrome's capture window to finish, then poll for the trace file to be
        // fully flushed to disk before tearing down the detached `theia` process tree.
        // On slower systems (notably Linux), Chrome can take several seconds longer than
        // the configured capture duration to write out the (large) trace JSON, so we apply
        // a 30-second backstop instead of a fixed extra delay.
        await delay(traceConfigTemplate.startup_duration * 1_000);
        const traceFileReady = await waitForFileStable(traceFile, 30_000);
        if (!traceFileReady) {
            console.warn(`Trace file ${traceFile} did not stabilize within 30 seconds; analysis may fail.`);
        }
        if (electron.exitCode === null) {
            process.kill(-electron.pid, 'SIGINT');
        }
        electron = undefined;
        return { traceFile, backendSettledSeconds, frontendSettledSeconds };
    };

    await measureMulti(name, runs, testScenario, [
        {
            scenario: lcp,
            analyze: ctx => analyzeTrace(ctx.traceFile, hasNonzeroTimestamp, isLCP)
        },
        {
            scenario: backendSettled,
            analyze: ctx => ctx.backendSettledSeconds
        },
        {
            scenario: frontendSettled,
            analyze: ctx => ctx.frontendSettledSeconds
        }
    ]);
    contributions.logSummary(name);
}

/**
 * Launch the Electron app as a detached child process with tracing configured to start
 * immediately upon launch. The child process is detached because otherwise the attempt
 * to signal it to terminate when the test run is complete will not terminate the entire
 * process tree but only the root `theia` process, leaving the electron app instance
 * running until eventually this script itself exits.
 *
 * @param {string} traceConfigPath the path to the tracing configuration file with which to initiate tracing
 * @returns {Promise<ChildProcess>} the Electron child process, if successfully launched
 */
async function launchElectron(traceConfigPath) {
    const args = ['start', workspace, '--plugins=local-dir:../../plugins',
        '--log-level=debug',
        `--trace-config-file=${traceConfigPath}`,
        // Force JSON output: since Chromium switched its default trace format to Perfetto protobuf,
        // even a `result_file` ending in `.json` writes protobuf unless this switch is set.
        '--trace-startup-format=json'];
    if (process.platform === 'linux') {
        args.push('--headless');
    }
    return spawn(theia, args, { cwd: electronExample, detached: true });
}

function hasNonzeroTimestamp(traceEvent) {
    return traceEvent.hasOwnProperty('ts') // The traces don't have explicit nulls or undefineds
        && traceEvent.ts > 0;
}

/**
 * Analyze a `chunk` of text on the standard error stream of the child process.
 * If running in debug mode, this will always at least print out the `chunk` to the console.
 * In addition, the text is analyzed to look for known conditions that will invalidate the
 * test procedure and cause the script to bail. These include:
 *
 * - the native browser modules not being built correctly for Electron
 *
 * @param {string} chunk a chunk of standard error text from the child process
 */
function analyzeStderr(chunk) {
    if (debugging) {
        console.error('>', chunk.trimEnd());
    }

    if (chunk.includes('Error: Module did not self-register')) {
        console.error('Native browser modules are not built properly. Please rebuild the workspace and try again.');
        exit(1);
    }
}
