// Copyright (C) 2021 The Android Open Source Project
//
// Licensed 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.

// This script takes care of:
// - The build process for the whole UI and the chrome extension.
// - The HTTP dev-server with live-reload capabilities.
// The reason why this is a hand-rolled script rather than a conventional build
// system is keeping incremental build fast and maintaining the set of
// dependencies contained.
// The only way to keep incremental build fast (i.e. O(seconds) for the
// edit-one-line -> reload html cycles) is to run both the TypeScript compiler
// and the rollup bundler in --watch mode. Any other attempt, leads to O(10s)
// incremental-build times.
// This script allows mixing build tools that support --watch mode (tsc and
// rollup) and auto-triggering-on-file-change rules via fs.watch.
// When invoked without any argument (e.g., for production builds), this script
// just runs all the build tasks serially. It doesn't to do any mtime-based
// check, it always re-runs all the tasks.
// When invoked with --watch, it mounts a pipeline of tasks based on fs.watch
// and runs them together with tsc --watch and rollup --watch.
// The output directory structure is carefully crafted so that any change to UI
// sources causes cascading triggers of the next steps.
// The overall build graph looks as follows:
// +----------------+      +-----------------------------+
// | protos/*.proto |----->| pbjs out/tsc/gen/protos.js  |--+
// +----------------+      +-----------------------------+  |
//                         +-----------------------------+  |
//                         | pbts out/tsc/gen/protos.d.ts|<-+
//                         +-----------------------------+
//                             |
//                             V      +-------------------------+
// +---------+              +-----+   |  out/tsc/frontend/*.js  |
// | ui/*.ts |------------->| tsc |-> +-------------------------+   +--------+
// +---------+              +-----+   | out/tsc/controller/*.js |-->| rollup |
//                            ^       +-------------------------+   +--------+
//                +------------+      |   out/tsc/engine/*.js   |       |
// +-----------+  |*.wasm.js   |      +-------------------------+       |
// |ninja *.cc |->|*.wasm.d.ts |                                        |
// +-----------+  |*.wasm      |-----------------+                      |
//                +------------+                 |                      |
//                                               V                      V
// +-----------+  +------+    +------------------------------------------------+
// | ui/*.scss |->| scss |--->|              Final out/dist/ dir               |
// +-----------+  +------+    +------------------------------------------------+
// +----------------------+   | +----------+ +---------+ +--------------------+|
// | src/assets/*.png     |   | | assets/  | |*.wasm.js| | frontend_bundle.js ||
// +----------------------+   | |  *.css   | |*.wasm   | +--------------------+|
// | buildtools/typefaces |-->| |  *.png   | +---------+ |  engine_bundle.js  ||
// +----------------------+   | |  *.woff2 |             +--------------------+|
// | buildtools/legacy_tv |   | |  tv.html |             |traceconv_bundle.js ||
// +----------------------+   | +----------+             +--------------------+|
//                            +------------------------------------------------+

import argparse from 'argparse';
import childProcess from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import zlib from 'node:zlib';
import {fileURLToPath} from 'node:url';

const pjoin = path.join;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const ROOT_DIR = path.dirname(__dirname); // The repo root.
const VERSION_SCRIPT = pjoin(ROOT_DIR, 'tools/write_version_header.py');
const DEFAULT_PORT = 10000;

const cfg = {
  minifyJs: '',
  noSourceMaps: false,
  noTreeshake: false,
  watch: false,
  verbose: false,
  debug: false,
  bigtrace: false,
  engineBench: false,
  startHttpServer: false,
  httpServerListenHost: '127.0.0.1',
  httpServerListenPort: undefined,
  onlyWasmMemory64: false,
  wasmModules: [],
  crossOriginIsolation: false,
  testFilter: '',
  noOverrideGnArgs: false,

  // The fields below will be changed by main() after cmdline parsing.
  // Directory structure:
  // out/xxx/    -> outDir         : Root build dir, for both ninja/wasm and UI.
  //   ui/       -> outUiDir       : UI dir. All outputs from this script.
  //    tsc/     -> outTscDir      : Transpiled .ts -> .js.
  //      gen/   -> outGenDir      : Auto-generated .ts/.js (e.g. protos).
  //    dist/    -> outDistRootDir : Only index.html and service_worker.js
  //      v1.2/  -> outDistDir     : JS bundles and assets
  //    chrome_extension/          : Chrome extension.
  outDir: pjoin(ROOT_DIR, 'out/ui'),
  version: '', // v1.2.3, derived from the CHANGELOG + git.
  outUiDir: '',
  outUiTestArtifactsDir: '',
  outDistRootDir: '',
  outTscDir: '',
  outGenDir: '',
  outDistDir: '',
  outExtDir: '',
  outBigtraceDistDir: '',
  outOpenPerfettoTraceDistDir: '',
  lockFile: '',
};

const RULES = [
  {r: /ui\/src\/assets\/index.html/, f: copyIndexHtml},
  {r: /ui\/src\/assets\/bigtrace.html/, f: copyBigtraceHtml},
  {r: /ui\/src\/open_perfetto_trace\/index.html/, f: copyOpenPerfettoTraceHtml},
  // engine_bench page; no-op without --enable-engine-bench.
  {r: /ui\/src\/engine_bench\/bench\.html$/, f: copyEngineBenchHtml},
  {r: /ui\/src\/assets\/((.*)[.]png)/, f: copyAssets},
  {r: /ui\/src\/assets\/(data_explorer\/base-page\.json)/, f: copyAssets},
  {r: /ui\/src\/assets\/(data_explorer\/examples\/(.*)[.]json)/, f: copyAssets},
  {r: /ui\/src\/assets\/(data_explorer\/node_info\/(.*)[.]md)/, f: copyAssets},
  {r: /buildtools\/typefaces\/(.+[.]woff2)/, f: copyAssets},
  {r: /buildtools\/catapult_trace_viewer\/(.+(js|html))/, f: copyAssets},
  {r: /ui\/src\/chrome_extension\/.*/, f: copyExtensionAssets},
  {r: /.*\/dist\/.+\/(?!manifest\.json).*/, f: genServiceWorkerManifestJson},
  {r: /.*\/dist\/.*[.](js|html|css|wasm)$/, f: notifyLiveServer},
];

const tasks = [];
let tasksTot = 0;
let tasksRan = 0;
const httpWatches = [];
const tStart = performance.now();
const subprocesses = [];
const LIVE_SERVER_DEBOUNCE_MS = 250;
let liveServerDebounceTimerId = 0;
const notifyLiveServerPendingFiles = new Set();

// Loads ~/.config/perfetto/ui-dev-server.env and injects any KEY=VALUE pairs
// into process.env, without overriding variables already set in the environment.
function loadDevServerEnvFile() {
  const home = process.env.HOME || process.env.USERPROFILE || '';
  const envFile = path.join(home, '.config', 'perfetto', 'ui-dev-server.env');
  let content;
  try {
    content = fs.readFileSync(envFile, 'utf8');
  } catch (e) {
    return; // File absent or unreadable — not an error.
  }
  for (const line of content.split('\n')) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const eqIdx = trimmed.indexOf('=');
    if (eqIdx < 0) continue;
    const key = trimmed.slice(0, eqIdx).trim();
    const value = trimmed.slice(eqIdx + 1).trim();
    if (!(key in process.env)) process.env[key] = value;
  }
}

async function main() {
  const parser = new argparse.ArgumentParser({
    formatter_class: argparse.RawDescriptionHelpFormatter,
    epilog: `
Env-var overrides:
  Any flag can be set via a PERFETTO_UI_<FLAG> environment variable,
  where <FLAG> is the flag name uppercased with hyphens replaced by
  underscores. Boolean flags are activated by "1" or "true". CLI flags
  always take precedence over environment variables.

  Examples:
    PERFETTO_UI_SERVE_HOST=0.0.0.0
    PERFETTO_UI_SERVE_PORT=10000
    PERFETTO_UI_NO_BUILD=1
    PERFETTO_UI_TITLE=my-instance

  Defaults can also be persisted in:
    ~/.config/perfetto/ui-dev-server.env
  (one KEY=VALUE per line, # comments supported). Shell env vars take
  precedence over the file.
`,
  });
  parser.add_argument('--out', {help: 'Output directory'});
  parser.add_argument('--minify-js', {
    help: 'Minify js files',
    choices: ['preserve_comments', 'all'],
  });
  parser.add_argument('--no-source-maps', {
    action: 'store_true',
    help: 'Skip source map generation entirely',
  });
  parser.add_argument('--no-treeshake', {
    action: 'store_true',
    help: 'Disable rollup tree-shaking (much faster incremental rebuilds)',
  });
  parser.add_argument('--watch', '-w', {action: 'store_true'});
  parser.add_argument('--serve', '-s', {action: 'store_true'});
  parser.add_argument('--serve-host', {help: '--serve bind host'});
  parser.add_argument('--serve-port', {help: '--serve bind port', type: 'int'});
  parser.add_argument('--verbose', '-v', {action: 'store_true'});
  parser.add_argument('--no-build', '-n', {action: 'store_true'});
  parser.add_argument('--no-wasm', '-W', {action: 'store_true'});
  parser.add_argument('--only-wasm-memory64', {action: 'store_true'});
  parser.add_argument('--run-unittests', '-t', {action: 'store_true'});
  parser.add_argument('--debug', '-d', {action: 'store_true'});
  parser.add_argument('--bigtrace', {action: 'store_true'});
  parser.add_argument('--enable-engine-bench', {
    action: 'store_true',
    help: 'Build the engine startup benchmark page (engine_bench.html) and ' +
          'its dedicated worker bundle. Off by default.',
  });
  parser.add_argument('--open-perfetto-trace', {action: 'store_true'});
  parser.add_argument('--interactive', '-i', {action: 'store_true'});
  parser.add_argument('--rebaseline', '-r', {action: 'store_true'});
  parser.add_argument('--no-depscheck', {action: 'store_true'});
  parser.add_argument('--cross-origin-isolation', {action: 'store_true'});
  parser.add_argument('--test-filter', '-f', {
    help: "filter Jest tests by regex, e.g. 'chrome_render'",
  });
  parser.add_argument('--no-override-gn-args', {action: 'store_true'});
  parser.add_argument('--typecheck', {
    action: 'store_true',
    help: 'Only type-check (tsc --noEmit), skip bundling',
  });
  parser.add_argument('--title', {
    help: 'Override the page title (useful for distinguishing multiple instances)',
  });

  // Load ~/.config/perfetto/ui-dev-server.env defaults, then map any
  // PERFETTO_UI_* env vars to synthetic argv entries prepended before the
  // real argv so that explicit CLI flags always take precedence.
  loadDevServerEnvFile();
  const envPrefix = 'PERFETTO_UI_';
  const syntheticArgv = [];
  for (const [key, val] of Object.entries(process.env)) {
    if (!key.startsWith(envPrefix)) continue;
    const flag =
      '--' + key.slice(envPrefix.length).toLowerCase().replace(/_/g, '-');
    const action = parser._actions.find((a) =>
      (a.option_strings || []).includes(flag),
    );
    if (!action) continue;
    const isBoolFlag = action.nargs === 0;
    if (isBoolFlag) {
      if (val === '1' || val.toLowerCase() === 'true') syntheticArgv.push(flag);
    } else {
      syntheticArgv.push(`${flag}=${val}`);
    }
  }
  const args = parser.parse_args([...syntheticArgv, ...process.argv.slice(2)]);
  const clean = !args.no_build;
  cfg.outDir = path.resolve(ensureDir(args.out || cfg.outDir));
  cfg.lockFile = pjoin(cfg.outDir, 'watch.lock');

  // Only create the build lock if we are actually going to build If --no-build
  // is passed, we can run simultaneoushy without worrying about the build lock,
  // since we won't be writing to the output directories.
  if (!args.no_build) {
    prepareBuildLock();
  }

  cfg.outUiDir = ensureDir(pjoin(cfg.outDir, 'ui'), clean);
  cfg.outUiTestArtifactsDir = ensureDir(pjoin(cfg.outDir, 'ui-test-artifacts'));
  cfg.outExtDir = ensureDir(pjoin(cfg.outUiDir, 'chrome_extension'));
  cfg.outDistRootDir = ensureDir(pjoin(cfg.outUiDir, 'dist'));
  const proc = exec('python3', [VERSION_SCRIPT, '--stdout'], {stdout: 'pipe'});
  cfg.version = proc.stdout.toString().trim();
  cfg.outDistDir = ensureDir(pjoin(cfg.outDistRootDir, cfg.version));
  cfg.outTscDir = ensureDir(pjoin(cfg.outUiDir, 'tsc'));
  cfg.outGenDir = ensureDir(pjoin(cfg.outUiDir, 'tsc/gen'));
  cfg.testFilter = args.test_filter || '';
  cfg.watch = !!args.watch;
  cfg.verbose = !!args.verbose;
  cfg.debug = !!args.debug;
  cfg.bigtrace = !!args.bigtrace;
  cfg.engineBench = !!args.enable_engine_bench;
  cfg.openPerfettoTrace = !!args.open_perfetto_trace;
  cfg.startHttpServer = args.serve;
  cfg.noOverrideGnArgs = !!args.no_override_gn_args;
  if (args.minify_js) {
    cfg.minifyJs = args.minify_js;
  }
  cfg.noSourceMaps = !!args.no_source_maps;
  cfg.noTreeshake = !!args.no_treeshake;
  if (args.bigtrace) {
    cfg.outBigtraceDistDir = ensureDir(pjoin(cfg.outDistDir, 'bigtrace'));
  }
  if (cfg.openPerfettoTrace) {
    cfg.outOpenPerfettoTraceDistDir = ensureDir(
      pjoin(cfg.outDistRootDir, 'open_perfetto_trace'),
    );
  }
  if (args.serve_host) {
    cfg.httpServerListenHost = args.serve_host;
  }
  if (args.serve_port) {
    cfg.httpServerListenPort = args.serve_port;
  }
  if (args.interactive) {
    process.env.PERFETTO_UI_TESTS_INTERACTIVE = '1';
  }
  if (args.rebaseline) {
    process.env.PERFETTO_UI_TESTS_REBASELINE = '1';
  }
  if (args.cross_origin_isolation) {
    cfg.crossOriginIsolation = true;
  }
  cfg.check = !!args.typecheck;
  cfg.onlyWasmMemory64 = !!args.only_wasm_memory64;
  cfg.titleOverride = args.title || '';
  cfg.wasmModules = ['traceconv', 'proto_utils', 'trace_processor_memory64'];
  if (!cfg.onlyWasmMemory64) {
    cfg.wasmModules.push('trace_processor');
  }

  function terminateChildProcesses() {
    for (const proc of subprocesses) {
      console.log(`Terminating child process with PID ${proc.pid}`);
      proc.kill(); // SIGTERM is the default
    }
  }

  // Called whenever the process exits due to:
  // 1. The JS loop running out of work - (normal exit or uncaught exception).
  // 2. Manually calling process.exit().
  process.on('exit', () => {
    terminateChildProcesses();
  });

  // Register signal handlers for the usual termination signals. Only handle
  // once per signal so we can then call process.kill(sig) in order for the
  // process to exit with the correct exit code.
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
    process.once(sig, () => {
      terminateChildProcesses();
      process.kill(process.pid, sig);
    });
  }

  if (!args.no_depscheck) {
    // Check that deps are current before starting.
    const installBuildDeps = pjoin(ROOT_DIR, 'tools/install-build-deps');
    const checkDepsPath = pjoin(cfg.outDir, '.check_deps');
    let args = [installBuildDeps, `--check-only=${checkDepsPath}`, '--ui'];

    if (process.platform === 'darwin') {
      const result = childProcess.spawnSync('arch', ['-arm64', 'true']);
      const isArm64Capable = result.status === 0;
      if (isArm64Capable) {
        const archArgs = ['arch', '-arch', 'arm64'];
        args = archArgs.concat(args);
      }
    }
    const cmd = args.shift();
    exec(cmd, args);
  }

  console.log('Entering', cfg.outDir);
  process.chdir(cfg.outDir);

  // Enqueue empty task. This is needed only for --no-build --serve. The HTTP
  // server is started when the task queue reaches quiescence, but it takes at
  // least one task for that.
  addTask(() => {});

  if (args.no_build && cfg.check) {
    // --no-build --typecheck: just run tsc --noEmit assuming out dir exists.
    const tsProjects = ['ui', 'ui/src/service_worker'];
    if (cfg.bigtrace) tsProjects.push('ui/src/bigtrace');
    if (cfg.openPerfettoTrace) tsProjects.push('ui/src/open_perfetto_trace');
    for (const prj of tsProjects) {
      transpileTsProject(prj, {noEmit: true});
    }
  } else if (!args.no_build) {
    updateSymlinks(); // Links //ui/out -> //out/xxx/ui/

    buildWasm(args.no_wasm);
    copySyntaqliteRuntime();
    scanDir('ui/src/assets');
    scanDir('ui/src/chrome_extension');
    scanDir('buildtools/typefaces');
    scanDir('buildtools/catapult_trace_viewer');
    compileProtos();
    genVersion();
    generateStdlibDocs();

    const tsProjects = ['ui', 'ui/src/service_worker'];
    if (cfg.bigtrace) tsProjects.push('ui/src/bigtrace');
    if (cfg.openPerfettoTrace) {
      scanDir('ui/src/open_perfetto_trace');
      tsProjects.push('ui/src/open_perfetto_trace');
    }
    if (cfg.engineBench) {
      scanDir('ui/src/engine_bench');
    }

    if (cfg.check) {
      for (const prj of tsProjects) {
        transpileTsProject(prj, {noEmit: true});
      }
    } else {
      // Vite owns TS transpile + bundling. tsc is invoked separately purely
      // for type checking. In non-watch builds it runs synchronously and a
      // type error fails the build. In watch mode tsc --watch runs async in
      // the background and prints errors without killing the build.
      for (const prj of tsProjects) {
        if (cfg.watch) {
          transpileTsProject(prj, {
            watch: true,
            noEmit: true,
            noErrCheck: true,
          });
        } else {
          transpileTsProject(prj, {noEmit: true});
        }
      }
      runVite();
      genServiceWorkerManifestJson();

      // Watches the /dist. When changed:
      // - Notifies the HTTP live reload clients.
      // - Regenerates the ServiceWorker file map.
      scanDir(cfg.outDistRootDir);
    }
  }

  // We should enter the loop only in watch mode, where tsc and rollup are
  // asynchronous because they run in watch mode.
  if (args.no_build && !isDistComplete()) {
    console.log('No build was requested, but artifacts are not available.');
    console.log('In case of execution error, re-run without --no-build.');
  }
  if (!args.no_build && !cfg.check) {
    const tStart = performance.now();
    while (!isDistComplete()) {
      const secs = Math.ceil((performance.now() - tStart) / 1000);
      process.stdout.write(
        `\t\tWaiting for first build to complete... ${secs} s\r`,
      );
      await new Promise((r) => setTimeout(r, 500));
    }
  }
  if (cfg.watch) console.log('\nFirst build completed!');

  if (cfg.startHttpServer) {
    if (cfg.watch) {
      await startViteDevServer();
    } else {
      startServer();
    }
  }
  if (args.run_unittests) {
    runTests();
  }
}

// -----------
// Build rules
// -----------

function runTests() {
  // Vitest reads ui/vitest.config.mjs by default. ts is transpiled on the fly
  // by Vite, so there's no tsc-emitted .js layer involved.
  const args = [
    cfg.watch ? 'watch' : 'run',
    '--config',
    pjoin(ROOT_DIR, 'ui/vitest.config.mjs'),
  ];
  if (cfg.testFilter.length > 0) {
    args.push('-t', cfg.testFilter);
  }
  if (cfg.watch) {
    addTask(execModule, ['vitest', args, {async: true}]);
  } else {
    addTask(execModule, ['vitest', args]);
  }
}

function cpHtml(src, filename) {
  let html = fs.readFileSync(src).toString();
  // First copy the html as-is into the dist/v1.2.3/ directory. This is
  // only used for archival purporses, so one can open
  // ui.perfetto.dev/v1.2.3/ to skip the auto-update and channel logic.
  fs.writeFileSync(pjoin(cfg.outDistDir, filename), html);

  // Then copy it into the dist/ root by patching the version code.
  // TODO(primiano): in next CLs, this script should take a
  // --release_map=xxx.json argument, to populate this with multiple channels.
  const versionMap = JSON.stringify({stable: cfg.version});
  const bodyRegex = /data-perfetto_version='[^']*'/;
  html = html.replace(bodyRegex, `data-perfetto_version='${versionMap}'`);

  // If --title was provided, patch the page title. Useful when running
  // multiple dev server instances to distinguish browser tabs.
  if (cfg.titleOverride) {
    html = html.replace(
      /<title>[^<]*<\/title>/,
      `<title>${cfg.titleOverride}</title>`,
    );
  }

  fs.writeFileSync(pjoin(cfg.outDistRootDir, filename), html);
}

function copyIndexHtml(src) {
  addTask(cpHtml, [src, 'index.html']);
}

function copyBigtraceHtml(src) {
  if (cfg.bigtrace) {
    addTask(cpHtml, [src, 'bigtrace.html']);
  }
}

function copyOpenPerfettoTraceHtml(src) {
  if (cfg.openPerfettoTrace) {
    addTask(cp, [src, pjoin(cfg.outOpenPerfettoTraceDistDir, 'index.html')]);
  }
}

function copyEngineBenchHtml(src) {
  if (!cfg.engineBench) return;
  // Goes next to engine_bench_bundle.js so its relative <script> resolves.
  addTask(cp, [src, pjoin(cfg.outDistDir, 'engine_bench.html')]);
  addTask(makeEngineBenchRedirect, []);
}

function makeEngineBenchRedirect() {
  const target = `${cfg.version}/engine_bench.html`;
  // Redirect via JS so the bench knob query string is preserved.
  const html =
    '<!DOCTYPE html><meta charset="utf-8">' +
    '<title>Perfetto engine bench (redirect)</title>' +
    `<script>location.replace(${JSON.stringify(target)} + location.search + ` +
    'location.hash);</script>' +
    `<noscript><meta http-equiv="refresh" content="0; url=${target}">` +
    `<p>Redirecting to <a href="${target}">${target}</a>.</p></noscript>\n`;
  fs.writeFileSync(pjoin(cfg.outDistRootDir, 'engine_bench.html'), html);
}

function copyAssets(src, dst) {
  addTask(cp, [src, pjoin(cfg.outDistDir, 'assets', dst)]);
  if (cfg.bigtrace) {
    addTask(cp, [src, pjoin(cfg.outBigtraceDistDir, 'assets', dst)]);
  }
}

function copyUiTestArtifactsAssets(src, dst) {
  addTask(cp, [src, pjoin(cfg.outUiTestArtifactsDir, dst)]);
}

function compileProtos() {
  const dstJs = pjoin(cfg.outGenDir, 'protos.js');
  const dstTs = pjoin(cfg.outGenDir, 'protos.d.ts');
  const inputs = [
    'protos/perfetto/ipc/consumer_port.proto',
    'protos/perfetto/ipc/wire_protocol.proto',
    'protos/perfetto/trace/perfetto/perfetto_metatrace.proto',
    'protos/perfetto/perfetto_sql/structured_query.proto',
    'protos/perfetto/trace_processor/trace_processor.proto',
  ];
  // Can't put --no-comments here - The comments are load bearing for
  // the pbts invocation which follows.
  const pbjsArgs = [
    '--no-beautify',
    '--force-number',
    '--no-delimited',
    '--no-verify',
    '-t',
    'static-module',
    '-w',
    'es6',
    '-p',
    ROOT_DIR,
    '-o',
    dstJs,
  ].concat(inputs);
  addTask(execModule, ['pbjs', pbjsArgs]);

  // Note: If you are looking into slowness of pbts it is not pbts
  // itself that is slow. It invokes jsdoc to parse the comments out of
  // the |dstJs| with https://github.com/hegemonic/catharsis which is
  // pinning a CPU core the whole time.
  const pbtsArgs = ['--no-comments', '-p', ROOT_DIR, '-o', dstTs, dstJs];
  addTask(execModule, ['pbts', pbtsArgs]);
}

// Generates a .ts source that defines the VERSION and SCM_REVISION constants.
function genVersion() {
  const cmd = 'python3';
  const args = [
    VERSION_SCRIPT,
    '--ts_out',
    pjoin(cfg.outGenDir, 'perfetto_version.ts'),
  ];
  addTask(exec, [cmd, args]);
}

function generateStdlibDocs() {
  const cmd = pjoin(ROOT_DIR, 'tools/gen_stdlib_docs_json.py');
  const stdlibDir = pjoin(ROOT_DIR, 'src/trace_processor/perfetto_sql/stdlib');

  const stdlibFiles = listFilesRecursive(stdlibDir).filter(
    (filePath) => path.extname(filePath) === '.sql',
  );

  addTask(exec, [
    cmd,
    [
      '--json-out',
      pjoin(cfg.outDistDir, 'stdlib_docs.json'),
      '--minify',
      ...stdlibFiles,
    ],
  ]);
}

function updateSymlinks() {
  // /ui/out -> /out/ui.
  mklink(cfg.outUiDir, pjoin(ROOT_DIR, 'ui/out'));

  // /ui/src/gen -> /out/ui/ui/tsc/gen)
  mklink(cfg.outGenDir, pjoin(ROOT_DIR, 'ui/src/gen'));

  // /out/ui/test/data -> /test/data (For UI tests).
  mklink(
    pjoin(ROOT_DIR, 'test/data'),
    pjoin(ensureDir(pjoin(cfg.outDir, 'test')), 'data'),
  );

  // Creates a out/dist_version -> out/dist/v1.2.3 symlink, so rollup config
  // can point to that without having to know the current version number.
  mklink(
    path.relative(cfg.outUiDir, cfg.outDistDir),
    pjoin(cfg.outUiDir, 'dist_version'),
  );

  mklink(
    pjoin(ROOT_DIR, 'ui/node_modules'),
    pjoin(cfg.outTscDir, 'node_modules'),
  );
}

// Invokes ninja for building the {trace_processor, traceconv} Wasm modules.
// It copies the .wasm directly into the out/dist/ dir, and the .js/.ts into
// out/tsc/, so the typescript compiler and the bundler can pick them up.
function buildWasm(skipWasmBuild) {
  if (!skipWasmBuild) {
    if (!cfg.noOverrideGnArgs) {
      let gnVars = `is_debug=${cfg.debug}`;
      if (childProcess.spawnSync('which', ['ccache']).status === 0) {
        gnVars += ` cc_wrapper="ccache"`;
      }
      const gnArgs = ['gen', `--args=${gnVars}`, cfg.outDir];
      addTask(exec, [pjoin(ROOT_DIR, 'tools/gn'), gnArgs]);
    }
    const ninjaArgs = ['-C', cfg.outDir];
    ninjaArgs.push(...cfg.wasmModules.map((x) => `${x}_wasm`));
    addTask(exec, [pjoin(ROOT_DIR, 'tools/ninja'), ninjaArgs]);
  }

  for (const wasmMod of cfg.wasmModules) {
    const isMem64 = wasmMod.endsWith('_memory64');
    const wasmOutDir = pjoin(cfg.outDir, isMem64 ? 'wasm_memory64' : 'wasm');
    // The .wasm file goes directly into the dist dir (also .map in debug)
    for (const ext of ['.wasm'].concat(cfg.debug ? ['.wasm.map'] : [])) {
      const src = `${wasmOutDir}/${wasmMod}${ext}`;
      addTask(cp, [src, pjoin(cfg.outDistDir, wasmMod + ext)]);
    }
    // The .js / .ts go into intermediates, they will be bundled by rollup.
    for (const ext of ['.js', '.d.ts']) {
      const fname = `${wasmMod}${ext}`;
      addTask(cp, [pjoin(wasmOutDir, fname), pjoin(cfg.outGenDir, fname)]);
    }
  }
}

function copySyntaqliteRuntime() {
  const srcDir = pjoin(ROOT_DIR, 'ui/node_modules/syntaqlite/wasm');
  const dstDir = pjoin(cfg.outDistDir, 'assets');
  for (const fname of [
    'syntaqlite-runtime.js',
    'syntaqlite-runtime.wasm',
    'syntaqlite-sqlite.wasm',
  ]) {
    addTask(cp, [pjoin(srcDir, fname), pjoin(dstDir, fname)]);
  }
  addTask(buildSyntaqlitePerfettoDialect, []);
}

function getBuildToolsBinDir() {
  function getBinDirName() {
    switch (process.platform) {
      case 'darwin':
        return 'mac';
      case 'linux':
        return 'linux64';
      default:
        throw new Error(`Unsupported platform: ${process.platform}`);
    }
  }

  return pjoin(ROOT_DIR, 'buildtools', getBinDirName());
}

function buildSyntaqlitePerfettoDialect() {
  const buildToolsBinDir = getBuildToolsBinDir();
  const emcc = pjoin(buildToolsBinDir, 'emsdk/emscripten/emcc');
  const src = pjoin(
    ROOT_DIR,
    'src/trace_processor/perfetto_sql/syntaqlite/syntaqlite_perfetto.c',
  );
  const dst = pjoin(cfg.outDistDir, 'assets', 'syntaqlite-perfetto.wasm');
  try {
    const srcMtime = fs.statSync(src).mtimeMs;
    const dstMtime = fs.statSync(dst).mtimeMs;
    if (dstMtime >= srcMtime) return;
  } catch (e) {
    /* dst missing → rebuild */
  }
  ensureDir(path.dirname(dst));
  const emConfig = pjoin(ROOT_DIR, 'gn/standalone/.emscripten');
  const prevEmConfig = process.env.EM_CONFIG;
  process.env.EM_CONFIG = emConfig;
  try {
    exec(emcc, [
      '-O2',
      '-sSIDE_MODULE=2',
      '-sEXPORTED_FUNCTIONS=_syntaqlite_perfetto_dialect_template',
      '-o',
      dst,
      src,
    ]);
  } finally {
    if (prevEmConfig === undefined) delete process.env.EM_CONFIG;
    else process.env.EM_CONFIG = prevEmConfig;
  }
}

// This transpiles all the sources (frontend, controller, engine, extension) in
// one go. The only project that has a dedicated invocation is service_worker.
function transpileTsProject(project, options) {
  const args = ['--project', pjoin(ROOT_DIR, project)];
  options = options || {};

  if (options.noEmit) args.push('--noEmit');

  if (options.watch) {
    args.push('--watch', '--preserveWatchOutput');
    addTask(execModule, [
      'tsc',
      args,
      {
        async: true,
        noErrCheck: options.noErrCheck,
      },
    ]);
  } else if (options.noEmit) {
    addTask(execModule, ['tsc', args]);
  } else {
    addTask(execModule, ['tsc', args, {noErrCheck: options.noErrCheck}]);
  }
}

// Runs `vite build` (optionally in --watch mode) to transpile TS and produce
// the {frontend, engine, traceconv}_bundle.js files in cfg.outDistDir. All
// configuration lives in ui/vite.config.mjs; flags are passed via env vars to
// keep parity with the old rollup.config.js conventions.
//
// In watch+serve mode the frontend bundle is replaced by an in-process Vite
// dev server (see startViteDevServer) — workers and the service worker still
// go through `vite build --watch` because they're loaded as separate files by
// `new Worker(assetSrc(...))` / SW registration.
function runVite() {
  const baseEnv = {
    NO_SOURCE_MAPS: cfg.noSourceMaps ? 'true' : '',
    NO_TREESHAKE: cfg.noTreeshake ? 'true' : '',
    MINIFY_JS: cfg.minifyJs || '',
    IS_MEMORY64_ONLY: cfg.onlyWasmMemory64 ? 'true' : '',
  };
  const useDevServer = cfg.watch && cfg.startHttpServer;
  const bundles = ['engine', 'traceconv', 'service_worker', 'chrome_extension'];
  if (!useDevServer) bundles.unshift('frontend');
  if (cfg.bigtrace) bundles.push('bigtrace');
  if (cfg.engineBench) bundles.push('engine_bench', 'engine_bench_worker');
  if (cfg.openPerfettoTrace) bundles.push('open_perfetto_trace');
  for (const bundle of bundles) {
    const args = ['build', '--config', pjoin(ROOT_DIR, 'ui/vite.config.mjs')];
    if (cfg.watch) args.push('--watch');
    if (!cfg.verbose) args.push('--logLevel', 'warn');
    addTask(execModule, [
      'vite',
      args,
      {
        async: cfg.watch,
        env: {...baseEnv, BUNDLE: bundle},
      },
    ]);
  }
}

function genServiceWorkerManifestJson() {
  function makeManifest() {
    const manifest = {resources: {}};
    // When building the subresource manifest skip source maps, the manifest
    // itself and the copy of the index.html which is copied under /v1.2.3/.
    // The root /index.html will be fetched by service_worker.js separately.
    const skipRegex = /(\.map|manifest\.json|index.html)$/;
    walk(
      cfg.outDistDir,
      (absPath) => {
        const contents = fs.readFileSync(absPath);
        const relPath = path.relative(cfg.outDistDir, absPath);
        const b64 = crypto
          .createHash('sha256')
          .update(contents)
          .digest('base64');
        manifest.resources[relPath] = 'sha256-' + b64;
      },
      skipRegex,
    );
    const manifestJson = JSON.stringify(manifest, null, 2);
    fs.writeFileSync(pjoin(cfg.outDistDir, 'manifest.json'), manifestJson);
  }
  addTask(makeManifest, []);
}

// In dev (--watch --serve), Vite owns the user-facing port. It serves the
// frontend entry as native ESM transformed on the fly; everything else
// (wasm, fonts, css, /test/, /v1.2.3/-relative paths) is layered on as
// middleware. The custom HTTP server (startServer) is bypassed.
async function startViteDevServer() {
  // vite.config.mjs reads these at import time; set before createServer().
  if (cfg.onlyWasmMemory64) process.env.IS_MEMORY64_ONLY = 'true';
  if (cfg.noSourceMaps) process.env.NO_SOURCE_MAPS = 'true';
  if (cfg.noTreeshake) process.env.NO_TREESHAKE = 'true';
  if (cfg.minifyJs) process.env.MINIFY_JS = cfg.minifyJs;

  const {createServer} = await import('vite');
  const port = cfg.httpServerListenPort ?? DEFAULT_PORT;

  const headers = cfg.crossOriginIsolation
    ? {
        'Cross-Origin-Opener-Policy': 'same-origin',
        'Cross-Origin-Embedder-Policy': 'require-corp',
      }
    : undefined;

  const server = await createServer({
    configFile: pjoin(ROOT_DIR, 'ui/vite.config.mjs'),
    server: {
      host: cfg.httpServerListenHost,
      port,
      strictPort: false,
      headers,
      // Vite needs to read source files outside its root (ui/src/assets,
      // ui/src/gen via the symlink to out/, buildtools/, etc.).
      fs: {allow: [ROOT_DIR]},
    },
  });

  // Read the source index.html, patch for dev (point the inline bootstrap
  // at the frontend source entry), and serve at /.
  const indexSrc = pjoin(ROOT_DIR, 'ui/src/assets/index.html');
  const patchHtml = (raw) => {
    // Replace the prod-relative bundle path with the dev source entry.
    // Vite's transformIndexHtml will then resolve /frontend/index.ts through
    // its module graph (with HMR client injected).
    let html = raw.replace(
      /script\.src\s*=\s*version\s*\+\s*['"]\/frontend_bundle\.js['"];?/,
      `script.src = '/frontend/index.ts';`,
    );
    // Native ESM entry needs type="module". Vite also needs a global hint at
    // where versioned assets live (assetSrc()/getServingRoot() use it).
    html = html.replace(
      /script\.async\s*=\s*true;?/,
      `script.type = 'module'; window.__GLOBAL_ASSET_ROOT__ = version + '/';`,
    );
    // Patch the version map (same job as cpHtml in prod). In dev there is
    // exactly one channel served from the root, version '.'.
    const versionMap = JSON.stringify({stable: cfg.version});
    html = html.replace(
      /data-perfetto_version='[^']*'/,
      `data-perfetto_version='${versionMap}'`,
    );
    if (cfg.titleOverride) {
      html = html.replace(
        /<title>[^<]*<\/title>/,
        `<title>${cfg.titleOverride}</title>`,
      );
    }
    return html;
  };

  // Serve /test/* from the repo (used by some e2e flows). Mirrors the
  // equivalent branch in startServer().
  server.middlewares.use((req, res, next) => {
    const url = req.url.split('?', 1)[0];
    if (!url.startsWith('/test/')) return next();
    const absPath = pjoin(ROOT_DIR, url);
    if (path.relative(ROOT_DIR, absPath).startsWith('..')) {
      res.statusCode = 403;
      return res.end('403');
    }
    fs.readFile(absPath, (err, data) => {
      if (err) {
        res.statusCode = 404;
        return res.end();
      }
      res.end(data);
    });
  });

  // frontend/index.ts inserts `<link rel="stylesheet" href="frontend.css">`
  // at runtime, which is needed in prod but in dev the styles come from the
  // SCSS module that Vite transforms inline. Return a 200 empty body so the
  // link's onload fires and init can proceed.
  server.middlewares.use((req, res, next) => {
    const url = req.url.split('?', 1)[0];
    if (url === '/frontend.css' || url.endsWith('/frontend.css')) {
      res.setHeader('Content-Type', 'text/css');
      return res.end('/* dev stub: styles injected by Vite */');
    }
    next();
  });

  // Fall back to serving files from outDistRootDir / outDistDir for anything
  // Vite hasn't claimed (wasm modules, fonts under /assets/, manifest.json,
  // etc.). In prod, frontend.css lives inside the versioned dir, so the
  // url('assets/Roboto.woff2') paths in typefaces.scss resolve to
  // /<version>/assets/...; in dev the stylesheet is injected from /, so the
  // browser asks for /assets/Roboto.woff2. Try outDistDir as a second root
  // so those font requests succeed.
  server.middlewares.use((req, res, next) => {
    const url = req.url.split('?', 1)[0];
    // Vite handles JS/TS/CSS modules and HMR endpoints itself.
    if (url === '/' || url === '/index.html') return next();
    const roots = [cfg.outDistRootDir, cfg.outDistDir];
    const tryRoot = (i) => {
      if (i >= roots.length) return next();
      const root = roots[i];
      const absPath = path.normalize(pjoin(root, url));
      if (path.relative(root, absPath).startsWith('..')) return tryRoot(i + 1);
      fs.stat(absPath, (err, stat) => {
        if (err || !stat.isFile()) return tryRoot(i + 1);
        fs.readFile(absPath, (rerr, data) => {
          if (rerr) return tryRoot(i + 1);
          const ext = url.split('.').pop();
          const mime =
            {
              wasm: 'application/wasm',
              woff2: 'font/woff2',
              png: 'image/png',
              json: 'application/json',
              css: 'text/css',
              js: 'application/javascript',
              html: 'text/html',
            }[ext] || 'application/octet-stream';
          res.setHeader('Content-Type', mime);
          res.end(data);
        });
      });
    };
    tryRoot(0);
  });

  // Last: serve the patched index.html at / (and any other unmatched route,
  // mirroring the SPA convention).
  server.middlewares.use(async (req, res, next) => {
    const url = req.url.split('?', 1)[0];
    if (url !== '/' && url !== '/index.html') return next();
    try {
      const raw = fs.readFileSync(indexSrc, 'utf8');
      const patched = patchHtml(raw);
      const transformed = await server.transformIndexHtml(url, patched);
      res.setHeader('Content-Type', 'text/html');
      res.end(transformed);
    } catch (e) {
      next(e);
    }
  });

  await server.listen();
  server.printUrls();
  // Make sure the dev server is shut down on process exit.
  subprocesses.push({pid: 'vite-dev', kill: () => server.close()});
}

function startServer() {
  const server = http.createServer(function (req, res) {
    console.debug(req.method, req.url);
    let uri = req.url.split('?', 1)[0];
    if (uri.endsWith('/')) {
      uri += 'index.html';
    }

    if (uri === '/live_reload') {
      // Implements the Server-Side-Events protocol.
      const head = {
        'Content-Type': 'text/event-stream',
        'Connection': 'keep-alive',
        'Cache-Control': 'no-cache',
      };
      res.writeHead(200, head);
      const arrayIdx = httpWatches.length;
      // We never remove from the array, the delete leaves an undefined item
      // around. It makes keeping track of the index easier at the cost of a
      // small leak.
      httpWatches.push(res);
      req.on('close', () => delete httpWatches[arrayIdx]);
      return;
    }

    let absPath = path.normalize(path.join(cfg.outDistRootDir, uri));
    // We want to be able to use the data in '/test/' for e2e tests.
    // However, we don't want do create a symlink into the 'dist/' dir,
    // because 'dist/' gets shipped on the production server.
    if (uri.startsWith('/test/')) {
      absPath = pjoin(ROOT_DIR, uri);
    }

    // Don't serve contents outside of the project root (b/221101533).
    if (path.relative(ROOT_DIR, absPath).startsWith('..')) {
      res.writeHead(403);
      res.end('403 Forbidden - Request path outside of the repo root');
      return;
    }

    let stat;
    try {
      stat = fs.statSync(absPath);
    } catch (statErr) {
      res.writeHead(404);
      res.end(JSON.stringify(statErr));
      return;
    }

    // Truncate to second precision: HTTP dates have 1s resolution, so the
    // sub-millisecond part of mtime would cause a permanent mismatch.
    const mtimeSec = Math.floor(stat.mtime.getTime() / 1000) * 1000;
    const mtimeStr = new Date(mtimeSec).toUTCString();

    // Return 304 if the browser's cached copy is still fresh.
    const ifModifiedSince = req.headers['if-modified-since'];
    if (
      ifModifiedSince !== undefined &&
      new Date(ifModifiedSince).getTime() >= mtimeSec
    ) {
      res.writeHead(304);
      res.end();
      return;
    }

    fs.readFile(absPath, function (err, data) {
      if (err) {
        res.writeHead(404);
        res.end(JSON.stringify(err));
        return;
      }

      const mimeMap = {
        html: 'text/html',
        css: 'text/css',
        js: 'application/javascript',
        wasm: 'application/wasm',
      };
      const ext = uri.split('.').pop();
      const cType = mimeMap[ext] || 'octect/stream';
      const acceptsGzip = (req.headers['accept-encoding'] || '').includes(
        'gzip',
      );
      const finalize = (body) => {
        const head = {
          'Content-Type': cType,
          'Content-Length': body.length,
          'Last-Modified': mtimeStr,
          'Cache-Control': 'no-cache',
        };
        if (acceptsGzip) head['Content-Encoding'] = 'gzip';
        if (cfg.crossOriginIsolation) {
          head['Cross-Origin-Opener-Policy'] = 'same-origin';
          head['Cross-Origin-Embedder-Policy'] = 'require-corp';
        }
        res.writeHead(200, head);
        res.write(body);
        res.end();
      };
      if (acceptsGzip) {
        zlib.gzip(data, (gzErr, compressed) => {
          finalize(gzErr ? data : compressed);
        });
      } else {
        finalize(data);
      }
    });
  });

  let port = cfg.httpServerListenPort ?? DEFAULT_PORT;
  let retryCount = 0;

  // Pick the next free port starting at 10000
  server.on('error', (e) => {
    if (e.code === 'EADDRINUSE') {
      if (cfg.httpServerListenPort === undefined) {
        if (retryCount <= 10) {
          // Try the next port.
          console.log(`Port ${port} is in use, trying ${port + 1}...`);
          ++port;
          ++retryCount;
          server.listen(port, cfg.httpServerListenHost);
        } else {
          console.error(
            `ERROR: Port ${port} is in use, and no free port found after 10 tries. Exiting.`,
          );
          process.exit(1);
        }
      } else {
        console.error(
          `ERROR: Port ${port} is in use, and --serve-port was explicitly set. Exiting.`,
        );
        process.exit(1);
      }
    } else {
      console.error('HTTP SERVER ERROR:', e);
      process.exit(1);
    }
  });

  server.listen(port, cfg.httpServerListenHost);

  server.on('listening', () => {
    const {address, port} = server.address();
    const host = address == '127.0.0.1' ? 'localhost' : address;
    console.log(`HTTP server is listening on http://${host}:${port}`);
  });
}

function isDistComplete() {
  // In watch+serve mode the frontend bundle and its CSS are served live by
  // the Vite dev server, never materialised on disk. Only require the
  // artifacts that genuinely have to exist before the user can load a trace.
  const useDevServer = cfg.watch && cfg.startHttpServer;
  const requiredArtifacts = [
    ...(useDevServer ? [] : ['frontend_bundle.js', 'frontend.css']),
    'engine_bundle.js',
    'traceconv_bundle.js',
    ...cfg.wasmModules.map((wasmMod) => `${wasmMod}.wasm`),
  ];
  const relPaths = new Set();
  walk(cfg.outDistDir, (absPath) => {
    relPaths.add(path.relative(cfg.outDistDir, absPath));
  });
  for (const fName of requiredArtifacts) {
    if (!relPaths.has(fName)) return false;
  }
  return true;
}

function notifyLiveServer(changedFile) {
  // Add file to pending set
  notifyLiveServerPendingFiles.add(changedFile);

  // Clear existing timeout if any
  if (liveServerDebounceTimerId > 0) {
    clearTimeout(liveServerDebounceTimerId);
  }

  // Set new timeout to batch notifications
  liveServerDebounceTimerId = setTimeout(() => {
    // Send all pending files in one batch
    for (const cli of httpWatches) {
      if (cli === undefined) continue;
      for (const file of notifyLiveServerPendingFiles) {
        cli.write('data: ' + path.relative(cfg.outDistRootDir, file) + '\n\n');
      }
    }
    notifyLiveServerPendingFiles.clear();
    liveServerDebounceTimerId = 0;
  }, LIVE_SERVER_DEBOUNCE_MS);
}

function copyExtensionAssets() {
  addTask(cp, [
    pjoin(ROOT_DIR, 'ui/src/assets/logo-128.png'),
    pjoin(cfg.outExtDir, 'logo-128.png'),
  ]);
  addTask(cp, [
    pjoin(ROOT_DIR, 'ui/src/chrome_extension/manifest.json'),
    pjoin(cfg.outExtDir, 'manifest.json'),
  ]);
}

// -----------------------
// Task chaining functions
// -----------------------

function addTask(func, args) {
  const task = new Task(func, args);
  for (const t of tasks) {
    if (t.identity === task.identity) {
      return;
    }
  }
  tasks.push(task);
  setTimeout(runTasks, 0);
}

function runTasks() {
  const snapTasks = tasks.splice(0); // snap = std::move(tasks).
  tasksTot += snapTasks.length;
  for (const task of snapTasks) {
    const DIM = '\u001b[2m';
    const BRT = '\u001b[37m';
    const RST = '\u001b[0m';
    const ms = (performance.now() - tStart) / 1000;
    const ts = `[${DIM}${ms.toFixed(3)}${RST}]`;
    const descr = task.description.substr(0, 80);
    console.log(`${ts} ${BRT}${++tasksRan}/${tasksTot}${RST}\t${descr}`);
    task.func.apply(/* this=*/ undefined, task.args);
  }
}

// Executes all the RULES that match the given |absPath|.
function scanFile(absPath) {
  console.assert(fs.existsSync(absPath));
  console.assert(path.isAbsolute(absPath));
  const normPath = path.relative(ROOT_DIR, absPath);
  for (const rule of RULES) {
    const match = rule.r.exec(normPath);
    if (!match || match[0] !== normPath) continue;
    const captureGroup = match.length > 1 ? match[1] : undefined;
    rule.f(absPath, captureGroup);
  }
}

// Walks the passed |dir| recursively and, for each file, invokes the matching
// RULES. If --watch is used, it also installs a fswatch() and re-triggers the
// matching RULES on each file change.
function scanDir(dir, regex) {
  const filterFn = regex ? (absPath) => regex.test(absPath) : () => true;
  const absDir = path.isAbsolute(dir) ? dir : pjoin(ROOT_DIR, dir);
  // Add a fs watch if in watch mode.
  if (cfg.watch) {
    fs.watch(absDir, {recursive: true}, (_eventType, relFilePath) => {
      const filePath = pjoin(absDir, relFilePath);
      if (!filterFn(filePath)) return;
      if (cfg.verbose) {
        console.log('File change detected', _eventType, filePath);
      }
      if (fs.existsSync(filePath)) {
        scanFile(filePath, filterFn);
      }
    });
  }
  walk(absDir, (f) => {
    if (filterFn(f)) scanFile(f);
  });
}

function exec(cmd, args, opts) {
  opts = opts || {};
  opts.stdout = opts.stdout || 'inherit';
  if (cfg.verbose) console.log(`${cmd} ${args.join(' ')}\n`);
  const spwOpts = {cwd: cfg.outDir, stdio: ['ignore', opts.stdout, 'inherit']};
  if (opts.env) {
    spwOpts.env = {...process.env, ...opts.env};
  }
  const checkExitCode = (code, signal) => {
    if (signal === 'SIGINT' || signal === 'SIGTERM') return;
    if (code !== 0 && !opts.noErrCheck) {
      console.error(`${cmd} ${args.join(' ')} failed with code ${code}`);
      process.exit(1);
    }
  };
  if (opts.async) {
    const proc = childProcess.spawn(cmd, args, spwOpts);
    const procIndex = subprocesses.length;
    subprocesses.push(proc);
    return new Promise((resolve, _reject) => {
      proc.on('exit', (code, signal) => {
        delete subprocesses[procIndex];
        checkExitCode(code, signal);
        resolve();
      });
    });
  } else {
    const spawnRes = childProcess.spawnSync(cmd, args, spwOpts);
    checkExitCode(spawnRes.status, spawnRes.signal);
    return spawnRes;
  }
}

function execModule(module, args, opts) {
  const modPath = pjoin(ROOT_DIR, 'ui/node_modules/.bin', module);
  return exec(modPath, args || [], opts);
}

// ------------------------------------------
// File system & subprocess utility functions
// ------------------------------------------

class Task {
  constructor(func, args) {
    this.func = func;
    this.args = args || [];
    // |identity| is used to dedupe identical tasks in the queue.
    this.identity = JSON.stringify([this.func.name, this.args]);
  }

  get description() {
    const ret = this.func.name.startsWith('exec') ? [] : [this.func.name];
    const flattenedArgs = [].concat(...this.args);
    for (const arg of flattenedArgs) {
      if (typeof arg === 'object' && arg !== null) {
        ret.push(JSON.stringify(arg));
        continue;
      }
      const argStr = `${arg}`;
      if (argStr.startsWith('/')) {
        ret.push(path.relative(cfg.outDir, arg));
      } else {
        ret.push(argStr);
      }
    }
    return ret.join(' ');
  }
}

function walk(dir, callback, skipRegex) {
  for (const child of fs.readdirSync(dir)) {
    const childPath = pjoin(dir, child);
    const stat = fs.lstatSync(childPath);
    if (skipRegex !== undefined && skipRegex.test(child)) continue;
    if (stat.isDirectory()) {
      walk(childPath, callback, skipRegex);
    } else if (!stat.isSymbolicLink()) {
      callback(childPath);
    }
  }
}

// Recursively build a list of files in a given directory and return a list of
// file paths, similar to `find -type f`.
function listFilesRecursive(dir) {
  const fileList = [];

  walk(dir, (filePath) => {
    fileList.push(filePath);
  });

  return fileList;
}

function ensureDir(dirPath, clean) {
  const exists = fs.existsSync(dirPath);
  if (exists && clean) {
    console.log('rm', dirPath);
    fs.rmSync(dirPath, {recursive: true});
  }
  if (!exists || clean) fs.mkdirSync(dirPath, {recursive: true});
  return dirPath;
}

function cp(src, dst) {
  ensureDir(path.dirname(dst));
  if (cfg.verbose) {
    console.log(
      'cp',
      path.relative(ROOT_DIR, src),
      '->',
      path.relative(ROOT_DIR, dst),
    );
  }
  fs.copyFileSync(src, dst);
}

function mklink(src, dst) {
  // If the symlink already points to the right place don't touch it. This is
  // to avoid changing the mtime of the ui/ dir when unnecessary.
  if (fs.existsSync(dst)) {
    if (fs.lstatSync(dst).isSymbolicLink() && fs.readlinkSync(dst) === src) {
      return;
    } else {
      fs.unlinkSync(dst);
    }
  }
  fs.symlinkSync(src, dst);
}

function prepareBuildLock() {
  if (fs.existsSync(cfg.lockFile)) {
    const oldPid = fs.readFileSync(cfg.lockFile, 'utf8').trim();
    let running = true;
    try {
      // Check if oldPid exists.
      process.kill(parseInt(oldPid), 0);
    } catch (e) {
      running = false;
    }
    if (running) {
      console.error(
        `Error: a build.mjs instance is already running (${cfg.lockFile} PID=${oldPid}).`,
      );
      console.error(
        'Hint: use --no-build (-n) to skip the build and avoid the lock.',
      );
      process.exit(1);
    } else {
      console.log(`Removing stale lock file for PID ${oldPid}`);
      fs.unlinkSync(cfg.lockFile);
    }
  }
  fs.writeFileSync(cfg.lockFile, process.pid.toString());
  process.on('exit', () => releaseBuildLock());
}

function releaseBuildLock() {
  if (fs.existsSync(cfg.lockFile)) {
    const pid = fs.readFileSync(cfg.lockFile, 'utf8').trim();
    if (pid === process.pid.toString()) {
      fs.unlinkSync(cfg.lockFile);
    } else {
      console.warn(`Ignoring stale lock file PID ${pid}`);
    }
  }
}

main();
