/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.  See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership.  The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License.  You may obtain a copy of the License at
*
*   http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied.  See the License for the
* specific language governing permissions and limitations
* under the License.
*/

const chalk = require('chalk');

process.on('uncaughtException', (err) => {
    if (err.message.includes('Cannot find module')) {
        console.error(chalk.red(err.message.split('\n')[0]));
        console.log();
        console.error(`Please run \`${chalk.yellow('npm install')}\` in \`${chalk.green('test/runTest')}\` folder first!`);
        console.log();
    } else {
        console.error('Uncaught err:', err);
    }
    process.exit(1);
});

const handler = require('serve-handler');
const http = require('http');
const path = require('path');
const {fork} = require('child_process');
const semver = require('semver');
const {port, origin} = require('./config');
const {
    getTestsList,
    updateTestsList,
    saveTestsList,
    mergeTestsResults,
    updateActionsMeta,
    getResultBaseDir,
    getRunHash,
    getAllTestsRuns,
    delTestsRun,
    RESULTS_ROOT_DIR,
    checkStoreVersion,
    clearStaledResults
} = require('./store');
const {prepareEChartsLib, getActionsFullPath, fetchVersions, cleanBranchDirectory, fetchRecentPRs} = require('./util');
const fse = require('fs-extra');
const fs = require('fs');
const open = require('open');
const genReport = require('./genReport');
const useCNMirror = process.argv.includes('--useCNMirror') || !!process.env.USE_CN_MIRROR;

console.info(chalk.green('useCNMirror:'), useCNMirror);

const CLI_FIXED_THREADS_COUNT = 1;

function serve() {
    clearStaledResults();

    const server = http.createServer((request, response) => {
        return handler(request, response, {
            cleanUrls: false,
            // Root folder of echarts
            public: __dirname + '/../../'
        });
    });

    server.listen(port, () => {
        // console.log(`Server started. ${origin}`);
    });


    const io = require('socket.io')(server);
    return {
        io
    };
};

let runningThreads = [];
let pendingTests;

function isRunning() {
    return runningThreads.length > 0;
}

function stopRunningTests() {
    if (isRunning()) {
        runningThreads.forEach(thread => thread.kill());
        runningThreads = [];
    }

    if (pendingTests) {
        pendingTests.forEach(testOpt => {
            if (testOpt.status === 'pending') {
                testOpt.status = 'unsettled';
            }
        });
        saveTestsList();
        pendingTests = null;
    }
}

class Thread {
    constructor() {
        this.tests = [];

        this.onExit;
        this.onUpdate;
    }

    fork(extraArgs) {
        let p = fork(path.join(__dirname, 'cli.js'), [
            '--tests', this.tests.map(testOpt => testOpt.name).join(','),
            ...extraArgs
        ]);
        this.p = p;

        // Finished one test
        p.on('message', testOpt => {
            mergeTestsResults([testOpt]);
            saveTestsList();
            this.onUpdate();
        });
        // Finished all
        p.on('exit', () => {
            this.p = null;
            setTimeout(this.onExit);
        });
    }

    kill() {
        if (this.p) {
            this.p.kill();
        }
    }
}

function startTests(testsNameList, socket, {
    noHeadless,
    threadsCount,
    replaySpeed,
    actualSource,
    actualVersion,
    expectedSource,
    expectedVersion,
    renderer,
    useCoarsePointer,
    noSave
}) {
    console.log('Received: ', testsNameList.join(','));

    return new Promise(resolve => {
        pendingTests = getTestsList().filter(testOpt => {
            return testsNameList.includes(testOpt.name);
        });

        if (!noSave) {
            pendingTests.forEach(testOpt => {
                // Reset all tests results
                testOpt.status = 'pending';
                testOpt.results = [];
            });
            // Save status immediately
            saveTestsList();

            socket.emit('update', {
                tests: getTestsList(),
                running: true
            });
        }

        threadsCount = Math.min(
            Math.ceil((threadsCount || 1) / CLI_FIXED_THREADS_COUNT),
            pendingTests.length
        );
        let runningCount = threadsCount;
        function onExit() {
            runningCount--;
            if (runningCount === 0) {
                runningThreads = [];
                resolve();
            }
        }
        function onUpdate() {
            // Merge tests.
            if (isRunning() && !noSave) {
                socket.emit('update', {
                    tests: getTestsList(),
                    running: true
                });
            }
        }

        // Assigning tests to threads
        runningThreads = new Array(threadsCount).fill(0).map(() => new Thread() );
        for (let i = 0; i < pendingTests.length; i++) {
            runningThreads[i % threadsCount].tests.push(pendingTests[i]);
        }
        for (let i = 0; i < runningThreads.length; i++) {
            runningThreads[i].onExit = onExit;
            runningThreads[i].onUpdate = onUpdate;
            runningThreads[i].fork([
                '--speed', replaySpeed || 5,
                '--actual', actualVersion,
                '--expected', expectedVersion,
                '--actual-source', actualSource,
                '--expected-source', expectedSource,
                '--renderer', renderer || '',
                '--use-coarse-pointer', useCoarsePointer,
                '--threads', Math.min(threadsCount, CLI_FIXED_THREADS_COUNT),
                '--dir', getResultBaseDir(),
                ...(noHeadless ? ['--no-headless'] : []),
                ...(noSave ? ['--no-save'] : [])
            ]);
        }
    });
}

function checkPuppeteer() {
    try {
        const packageConfig = require('puppeteer/package.json');
        console.log(`puppeteer version: ${packageConfig.version}`);
        return semver.satisfies(packageConfig.version, '>=9.0.0');
    }
    catch (e) {
        return false;
    }
}


async function start() {
    // Clean PR directories before starting
    const {cleanPRDirectories} = require('./util');
    cleanPRDirectories();

    if (!checkPuppeteer()) {
        console.error(`Can't find puppeteer >= 9.0.0, run 'npm install' to update in the 'test/runTest' folder`);
        return;
    }

    let stableVersions;
    let nightlyVersions;
    try {
        stableVersions = await fetchVersions(false, useCNMirror);
        nightlyVersions = (await fetchVersions(true, useCNMirror)).slice(0, 100);
    } catch (e) {
        console.error('Failed to fetch version list:', e);
        console.log(`Try again later or try the CN mirror with: ${chalk.yellow('npm run test:visual -- -- --useCNMirror')}`)
        return;
    }

    let _currentTestHash;
    let _currentRunConfig;

    // let runtimeCode = await buildRuntimeCode();
    // fse.outputFileSync(path.join(__dirname, 'tmp/testRuntime.js'), runtimeCode, 'utf-8');

    // Start a static server for puppeteer open the html test cases.
    let {io} = serve();

    io.of('/client').on('connect', async socket => {

        let isAborted = false;
        function abortTests() {
            if (!isRunning()) {
                return;
            }
            isAborted = true;
            stopRunningTests();
            io.of('/client').emit('abort');
        }

        socket.on('syncRunConfig', async ({
            runConfig,
            forceSet
        }) => {
            // First time open.
            if ((!_currentRunConfig || forceSet) && runConfig) {
                _currentRunConfig = runConfig;
            }

            if (!_currentRunConfig) {
                return;
            }

            socket.emit('syncRunConfig_return', {
                runConfig: _currentRunConfig,
                versions: stableVersions,
                nightlyVersions: nightlyVersions
            });

            if (_currentTestHash !== getRunHash(_currentRunConfig)) {
                abortTests();
            }
            await updateTestsList(
                _currentTestHash = getRunHash(_currentRunConfig),
                !isRunning() // Set to unsettled if not running
            );

            socket.emit('update', {
                tests: getTestsList(),
                running: isRunning()
            });
        });

        socket.on('getAllTestsRuns', async () => {
            socket.emit('getAllTestsRuns_return', {
                runs: await getAllTestsRuns()
            });
        });

        socket.on('genTestsRunReport', async (params) => {
            console.log('genTestsRunReport', params);
            const absPath = await genReport(
                path.join(RESULTS_ROOT_DIR, getRunHash(params))
            );
            const relativeUrl = path.join('../', path.relative(__dirname, absPath));
            socket.emit('genTestsRunReport_return', {
                reportUrl: relativeUrl
            });
        });

        socket.on('delTestsRun', async (params) => {
            delTestsRun(params.id);
            console.log('Deleted', params.id);
        });

        socket.on('run', async data => {
            stopRunningTests();
            isAborted = false;

            let startTime = Date.now();

            try {
                await prepareEChartsLib(data.expectedSource, data.expectedVersion, useCNMirror);
                await prepareEChartsLib(data.actualSource, data.actualVersion, useCNMirror);
            }
            catch (e) {
                console.error(e);
                // Send error to client
                socket.emit('run_error', {
                    message: e.toString()
                });
                return;
            }

            // If aborted in the time downloading lib.
            if (isAborted) {
                return;
            }

            // TODO Should broadcast to all sockets.
            if (!checkStoreVersion(data)) {
                throw new Error('Unmatched store version and run version.');
            }

            await startTests(
                data.tests,
                io.of('/client'),
                {
                    noHeadless: data.noHeadless,
                    threadsCount: data.threads,
                    replaySpeed: data.replaySpeed,
                    actualSource: data.actualSource,
                    actualVersion: data.actualVersion,
                    expectedSource: data.expectedSource,
                    expectedVersion: data.expectedVersion,
                    renderer: data.renderer,
                    useCoarsePointer: data.useCoarsePointer,
                    noSave: false
                }
            );

            if (!isAborted) {
                const deltaTime = Date.now() - startTime;
                console.log('Finished in ', Math.round(deltaTime / 1000) + ' second');
                io.of('/client').emit('finish', {
                    time: deltaTime,
                    count: data.tests.length,
                    threads: data.threads
                });
            }
            else {
                console.log('Aborted!');
            }
        });

        socket.on('stop', abortTests);
    });

    io.of('/recorder').on('connect', async socket => {
        socket.on('saveActions', data => {
            if (data.testName) {
                fse.outputFile(
                    getActionsFullPath(data.testName),
                    JSON.stringify(data.actions),
                    'utf-8'
                );
                updateActionsMeta(data.testName, data.actions);
            }
            // TODO Broadcast the change?
        });
        socket.on('changeTest', data => {
            try {
                const actionData = fs.readFileSync(getActionsFullPath(data.testName), 'utf-8');
                socket.emit('updateActions', {
                    testName: data.testName,
                    actions: JSON.parse(actionData)
                });
            }
            catch(e) {
                // Can't find file.
            }
        });
        socket.on('runSingle', async data => {
            stopRunningTests();

            try {
                await startTests([data.testName], socket, {
                    noHeadless: true,
                    threadsCount: 1,
                    replaySpeed: 2,
                    actualVersion: data.actualVersion,
                    expectedVersion: data.expectedVersion,
                    renderer: data.renderer || '',
                    useCoarsePointer: data.useCoarsePointer,
                    noSave: true
                });
            }
            catch (e) { console.error(e); }
            console.log('Finished');
            socket.emit('finish');
        });

        socket.emit('getTests', {
            // TODO updateTestsList.
            tests: getTestsList().map(test => {
                return {
                    name: test.name,
                    actions: test.actions
                };
            })
        });
    });

    console.log(`Dashboard: ${origin}/test/runTest/client/index.html`);
    console.log(`Interaction Recorder: ${origin}/test/runTest/recorder/index.html`);
    open(`${origin}/test/runTest/client/index.html`);

}

start();
