// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

/**
 * @module perspective_bench
 * The main entrypoint of `perspective_bench` is a CLI node.js application which
 * may star
 */

const MAX_ITERATIONS = 100;
const MIN_ITERATIONS = 5;
const WARM_UP_ITERATIONS = 10;

/**
 * Utility function to push/filter.
 * @param {*} a
 * @param {*} x
 */
function push_if(a, x) {
    if (x !== undefined) {
        a.push(x);
    }
}

/**
 * Calculate avg fora  key.
 * @param {*} a
 * @param {*} key
 * @returns
 */
function avg(a, key) {
    return a.map((x) => x[key]).reduce((x, y) => x + y, 0) / a.length / 1000;
}

/**
 * Calculate stddev for a key.
 * @param {*} array
 * @param {*} key
 * @returns
 */
function stddev(array, key) {
    const n = array.length;
    const mean = array.reduce((a, b) => a + b[key] / 1000, 0) / n;
    return Math.sqrt(
        array
            .map((x) => Math.pow(x[key] / 1000 - mean, 2))
            .reduce((a, b) => a + b) / n
    );
}

/**
 * Calculate outliers
 * @param {*} someArray
 * @returns
 */
function markOutliers(someArray) {
    var values = someArray.concat();
    values.sort(function (a, b) {
        return a.cpu_time - b.cpu_time;
    });

    var q1 = values[Math.floor(values.length / 4)].cpu_time;
    var q3 = values[Math.ceil(values.length * (3 / 4))].cpu_time;
    var iqr = q3 - q1;
    var maxValue = q3 + iqr * 1.5;
    var minValue = q1 - iqr * 1.5;
    return someArray.map(function (x) {
        x.outlier = !(x.cpu_time <= maxValue && x.cpu_time >= minValue);
        return x;
    });
}

/**
 * Convert a list to arrow and write it to disk. `@finos/perspective` is
 * imported in this scope to prevent interpreter-wide side effects of the
 * library from impacting forked processes, based on an observation that some
 * runs inline had anomalies across many observations that couldn't be explained
 * by contemporary system load.
 * @param {Array} obs_records an array of records to persist
 */
async function persist_to_arrow(benchmarks_table, out_path = "") {
    const fs = await import("node:fs");
    const view = await benchmarks_table.view();
    const arrow = await view.to_arrow();
    fs.writeFileSync(out_path, Buffer.from(arrow), "binary");
}

/**
 * Run a single benchmark case, reporting the runtime
 * @param {*} param0
 * @returns An observation record with timing and metadata fields for a single
 * iteration of this case.
 */
async function benchmark_case({
    args,
    before,
    test,
    benchmark,
    metadata,
    after,
    i,
}) {
    const { default: microtime } = await import("microtime");
    const args2 = args.slice();
    push_if(args2, await before?.(...args2));
    global.gc(false);
    await new Promise(setTimeout);
    const start_time = microtime.now();
    const start_cpu = process.cpuUsage();
    const x = await test(...args2);
    const end = process.cpuUsage(start_cpu);
    const end_time = microtime.now() - start_time;
    push_if(args2, x);
    await after?.(...args2);
    return {
        cpu_time: end.user + end.system,
        real_time: end_time,
        user_time: end.user,
        system_time: end.system,
        benchmark,
        iteration: i,
        ...structuredClone(metadata),
    };
}

/**
 * Run the benchmarks in a forked process and colelct the observations.
 * @param {{version: string, i: number}} version the versions spec to send to
 * the child process.
 * @returns an array of observation records for this version.
 */
async function benchmark_node_version(version, benchmarks_table) {
    const cp = await import("node:child_process");
    const process = await import("node:process");
    const path = await import("node:path");
    const suite_path = path.join(process.argv[1]);
    let stats = [];
    const worker = cp.fork(suite_path, {
        execArgv: ["--expose-gc"],
        env: { BENCH_FLAG: "1" },
    });

    let cont;
    worker.on("message", (details) => {
        if (details.finished) {
            cont();
        } else {
            benchmarks_table.update(details.obs_records);
            stats.push(details.stats);
        }
    });

    worker.send({
        ...version,
        stats,
    });

    await new Promise((r) => {
        cont = r;
    });

    worker.kill();
    return { stats };
}

/**
 * Run the benchmarks in a forked process and colelct the observations.
 * @param {{version: string, i: number}} version the versions spec to send to
 * the child process.
 * @returns an array of observation records for this version.
 */
async function benchmark_puppeteer_version(version, benchmarks_table) {
    const process = await import("node:process");
    const path = await import("node:path");
    const { default: puppeteer } = await import("puppeteer");
    const suite_path = path.join(process.argv[1]);
    let stats = [];
    const browser = await puppeteer.launch({ headless: false });
    const page = await browser.newPage();
    await page.goto("http://localhost:8081/empty.html");
    await page.setContent(`
    <html>
        <head>
            <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />

            <script type="module" src="/node_modules/@finos/perspective-viewer/dist/cdn/perspective-viewer.js"></script>

            <link rel="preload" href="/node_modules/@finos/perspective/dist/cdn/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
            <link rel="preload" href="/node_modules/@finos/perspective-viewer/dist/cdn/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
            <link rel="preload" href="/node_modules/superstore-arrow/superstore.lz4.arrow" as="fetch" type="arraybuffer" crossorigin="anonymous" />
        </head>
    </html>`);

    await page.evaluate(async function () {
        // const bench = await import("/tools/perspective-bench/basic_suite.mjs");
        // console.log(bench);
        // <script type="module">
        //     import perspective from "/node_modules/@finos/perspective/dist/cdn/perspective.js";
        //     const worker = await perspective.worker();
        //     const resp = await fetch("/node_modules/superstore-arrow/superstore.lz4.arrow");
        //     const arrow = await resp.arrayBuffer();
        //     const viewer = document.getElementsByTagName("perspective-viewer")[0];
        //     const table = worker.table(arrow);
        //     viewer.load(table);
        //     viewer.restore({ settings: true, plugin_config: { edit_mode: "EDIT" } });
        // </script>
        // <style>
        //     perspective-viewer {
        //         position: absolute;
        //         top: 0;
        //         left: 0;
        //         bottom: 0;
        //         right: 0;
        //     }
        // </style>
    });

    await page.waitForTimeout(100_000);

    // const worker = cp.fork(suite_path, {
    //     execArgv: ["--expose-gc"],
    //     env: { BENCH_FLAG: "1" },
    // });

    // let cont;
    // worker.on("message", (details) => {
    //     if (details.finished) {
    //         cont();
    //     } else {
    //         benchmarks_table.update(details.obs_records);
    //         stats.push(details.stats);
    //     }
    // });

    // worker.send({
    //     ...version,
    //     stats,
    // });

    // await new Promise((r) => {
    //     cont = r;
    // });

    // worker.kill();
    // return { stats };
}

/**
 * Register a benchmark for a test case.
 * @param {*} param0
 */
export async function benchmark({
    name: benchmark,
    before,
    before_all,
    test,
    after,
    after_all,
    args = [],
    metadata = {},
    warm_up_iterations = WARM_UP_ITERATIONS,
    max_iterations = MAX_ITERATIONS,
    min_iterations = MIN_ITERATIONS,
    max_time = 3_000_000,
} = {}) {
    const { default: process } = await import("node:process");
    const { default: microtime } = await import("microtime");

    let obs_records = [];
    push_if(args, await before_all?.(...args));
    const start_time = microtime.now();
    for (let i = 0; i < warm_up_iterations; i++) {
        await benchmark_case({
            args,
            before,
            test,
            benchmark,
            metadata,
            after,
            i,
        });
    }

    let i;
    for (i = 0; i < max_iterations; i++) {
        if (microtime.now() - start_time > max_time && i >= min_iterations) {
            break;
        }

        obs_records.push(
            await benchmark_case({
                args,
                before,
                test,
                benchmark,
                metadata,
                after,
                i,
            })
        );
    }

    obs_records = markOutliers(obs_records);
    const filtered = obs_records.filter((x) => !x.outlier);
    const n_outliers = obs_records.length - filtered.length;
    const filtered_avg_cpu = avg(filtered, "cpu_time");
    const filtered_avg_time = avg(filtered, "real_time").toFixed(3);
    const stddev_cpu = stddev(filtered, "cpu_time").toFixed(3);
    const stats = {
        benchmark,
        metadata,
        filtered_avg_cpu: filtered_avg_cpu.toFixed(3),
        stddev_percent: ((stddev_cpu / filtered_avg_cpu) * 100).toFixed(1),
        stddev_cpu,
        filtered_avg_time,
        hr_time: avg(filtered, "hr_time"),
        iterations: i,
        non_outliers: i - n_outliers,
    };

    console.log(
        ` - ${stats.filtered_avg_cpu}ms +/-${stats.stddev_percent}% (CPU), ${stats.filtered_avg_time}ms (Real) ${stats.non_outliers}/${stats.iterations} iterations - ${benchmark}`
    );

    await after_all?.(...args);
    process.send({ obs_records, stats });
}

function buffer_to_arraybuffer(buffer) {
    return new Int8Array(
        buffer.buffer.slice(
            buffer.byteOffset,
            buffer.byteOffset + buffer.length
        )
    );
}

/**
 * Host a Perspective server to view the live benchmark data as it accumulates.
 * @param {*} param0
 * @returns
 */
async function start_server({ cwd_static_file_handler, make_session }) {
    const { default: express } = await import("express");
    const { default: expressWs } = await import("express-ws");

    const app = expressWs(express()).app;
    app.ws("/subscribe", async (ws) => {
        const server = await make_session((proto) =>
            ws.send(buffer_to_arraybuffer(proto))
        );

        ws.on("message", (proto) =>
            server.handle_request(buffer_to_arraybuffer(proto))
        );
    });

    app.use("/", (x, y) =>
        cwd_static_file_handler(x, y, ["src/html/", "../.."])
    );

    const server = app.listen(8081, () => {
        const port = server.address().port;
        console.log(`Live benchmarks at http://localhost:${port}\n`);
    });

    return server;
}

/**
 * Register a suite of benchmarks to run against a set of packages with
 * similar APIs.
 * @param {*} versions
 * @param {*} run_version_callback
 */
export async function suite(
    versions,
    out_path,
    run_version_callback,
    start_server_callback,
    stop_server_callback
) {
    if (!!process.env.BENCH_FLAG) {
        const { default: process } = await import("node:process");
        process.on("message", async function bench_all({ path, i }) {
            await run_version_callback(path, i);
            process.send({ finished: true });
        });
    } else {
        const psp = await import("@finos/perspective");
        const benchmarks_table = await psp.default.table(
            {
                version: "string",
                cpu_time: "float",
                system_time: "float",
                user_time: "float",
                real_time: "float",
                version_idx: "integer",
                benchmark: "string",
                outlier: "boolean",
            },
            { name: "benchmarks" }
        );

        const app = await start_server(psp);

        for (let i = 0; i < versions.length; i++) {
            let s;
            if (start_server_callback) {
                s = await start_server_callback(versions[i], i);
            }

            await Promise.all([
                benchmark_node_version(
                    { path: versions[i], i },
                    benchmarks_table
                ),
                // benchmark_puppeteer_version(
                //     { path: versions[i], i },
                //     benchmarks_table
                // ),
                // benchmark_version({ path: versions[i], i }, benchmarks_table),
                // benchmark_version({ path: versions[i], i }, benchmarks_table),
                // benchmark_version({ path: versions[i], i }, benchmarks_table),
            ]);

            await persist_to_arrow(benchmarks_table, out_path);

            if (stop_server_callback) {
                await stop_server_callback(s, versions[i], i);
            }
        }

        await app.close();
        process.exit(0);
    }
}
