/*eslint-env node*/
"use strict";

const fs = require("fs");
const path = require("path");
const os = require("os");
const child_process = require("child_process");
const crypto = require("crypto");
const zlib = require("zlib");
const readline = require("readline");
const request = require("request");

const globby = require("globby");
const gulpTap = require("gulp-tap");
const gulpUglify = require("gulp-uglify");
const open = require("open");
const rimraf = require("rimraf");
const glslStripComments = require("glsl-strip-comments");
const mkdirp = require("mkdirp");
const mergeStream = require("merge-stream");
const streamToPromise = require("stream-to-promise");
const gulp = require("gulp");
const gulpInsert = require("gulp-insert");
const gulpZip = require("gulp-zip");
const gulpRename = require("gulp-rename");
const gulpReplace = require("gulp-replace");
const Promise = require("bluebird");
const Karma = require("karma");
const yargs = require("yargs");
const AWS = require("aws-sdk");
const mime = require("mime");
const rollup = require("rollup");
const rollupPluginStripPragma = require("rollup-plugin-strip-pragma");
const rollupPluginExternalGlobals = require("rollup-plugin-external-globals");
const rollupPluginUglify = require("rollup-plugin-uglify");
const rollupCommonjs = require("@rollup/plugin-commonjs");
const rollupResolve = require("@rollup/plugin-node-resolve").default;
const cleanCSS = require("gulp-clean-css");
const typescript = require("typescript");

const packageJson = require("./package.json");
let version = packageJson.version;
if (/\.0$/.test(version)) {
  version = version.substring(0, version.length - 2);
}

const karmaConfigFile = path.join(__dirname, "Specs/karma.conf.cjs");
const travisDeployUrl =
  "http://cesium-dev.s3-website-us-east-1.amazonaws.com/cesium/";

//Gulp doesn't seem to have a way to get the currently running tasks for setting
//per-task variables.  We use the command line argument here to detect which task is being run.
const taskName = process.argv[2];
const noDevelopmentGallery =
  taskName === "release" || taskName === "makeZipFile";
const minifyShaders =
  taskName === "minify" ||
  taskName === "minifyRelease" ||
  taskName === "release" ||
  taskName === "makeZipFile" ||
  taskName === "buildApps";

const verbose = yargs.argv.verbose;

let concurrency = yargs.argv.concurrency;
if (!concurrency) {
  concurrency = os.cpus().length;
}

// Work-around until all third party libraries use npm
const filesToLeaveInThirdParty = [
  "!Source/ThirdParty/Workers/basis_transcoder.js",
  "!Source/ThirdParty/basis_transcoder.wasm",
  "!Source/ThirdParty/google-earth-dbroot-parser.js",
  "!Source/ThirdParty/knockout*.js",
];

const sourceFiles = [
  "Source/**/*.js",
  "!Source/*.js",
  "!Source/Workers/**",
  "!Source/WorkersES6/**",
  "Source/WorkersES6/createTaskProcessorWorker.js",
  "!Source/ThirdParty/Workers/**",
  "!Source/ThirdParty/google-earth-dbroot-parser.js",
  "!Source/ThirdParty/_*",
];

const watchedFiles = [
  "Source/**/*.js",
  "!Source/Cesium.js",
  "!Source/Build/**",
  "!Source/Shaders/**/*.js",
  "Source/Shaders/**/*.glsl",
  "!Source/ThirdParty/Shaders/*.js",
  "Source/ThirdParty/Shaders/*.glsl",
  "!Source/Workers/**",
  "Source/Workers/cesiumWorkerBootstrapper.js",
  "Source/Workers/transferTypedArrayTest.js",
  "!Specs/SpecList.js",
];

const filesToClean = [
  "Source/Cesium.js",
  "Source/Shaders/**/*.js",
  "Source/Workers/**",
  "!Source/Workers/cesiumWorkerBootstrapper.js",
  "!Source/Workers/transferTypedArrayTest.js",
  "Source/ThirdParty/Shaders/*.js",
  "Specs/SpecList.js",
  "Apps/Sandcastle/jsHintOptions.js",
  "Apps/Sandcastle/gallery/gallery-index.js",
  "Apps/Sandcastle/templates/bucket.css",
  "Cesium-*.zip",
  "cesium-*.tgz",
];

const filesToConvertES6 = [
  "Source/**/*.js",
  "Specs/**/*.js",
  "!Source/ThirdParty/**",
  "!Source/Cesium.js",
  "!Source/copyrightHeader.js",
  "!Source/Shaders/**",
  "!Source/Workers/cesiumWorkerBootstrapper.js",
  "!Source/Workers/transferTypedArrayTest.js",
  "!Specs/karma-main.js",
  "!Specs/karma.conf.cjs",
  "!Specs/spec-main.js",
  "!Specs/SpecList.js",
  "!Specs/TestWorkers/**",
];

function rollupWarning(message) {
  // Ignore eval warnings in third-party code we don't have control over
  if (message.code === "EVAL" && /protobufjs/.test(message.loc.file)) {
    return;
  }

  console.log(message);
}

const copyrightHeader = fs.readFileSync(
  path.join("Source", "copyrightHeader.js"),
  "utf8"
);

function createWorkers() {
  rimraf.sync("Build/createWorkers");

  globby
    .sync([
      "Source/Workers/**",
      "!Source/Workers/cesiumWorkerBootstrapper.js",
      "!Source/Workers/transferTypedArrayTest.js",
    ])
    .forEach(function (file) {
      rimraf.sync(file);
    });

  const workers = globby.sync(["Source/WorkersES6/**"]);

  return rollup
    .rollup({
      input: workers,
      onwarn: rollupWarning,
    })
    .then(function (bundle) {
      return bundle.write({
        dir: "Build/createWorkers",
        banner:
          "/* This file is automatically rebuilt by the Cesium build process. */",
        format: "amd",
      });
    })
    .then(function () {
      return streamToPromise(
        gulp.src("Build/createWorkers/**").pipe(gulp.dest("Source/Workers"))
      );
    })
    .then(function () {
      rimraf.sync("Build/createWorkers");
    });
}

async function buildThirdParty() {
  rimraf.sync("Build/createWorkers");
  globby.sync(filesToLeaveInThirdParty).forEach(function (file) {
    rimraf.sync(file);
  });

  const workers = globby.sync(["ThirdParty/npm/**"]);

  return rollup
    .rollup({
      input: workers,
      plugins: [rollupResolve(), rollupCommonjs()],
      onwarn: rollupWarning,
    })
    .then(function (bundle) {
      return bundle.write({
        dir: "Build/createThirdPartyNpm",
        banner:
          "/* This file is automatically rebuilt by the Cesium build process. */",
        format: "es",
      });
    })
    .then(function () {
      return streamToPromise(
        gulp
          .src("Build/createThirdPartyNpm/**")
          .pipe(gulp.dest("Source/ThirdParty"))
      );
    })
    .then(function () {
      rimraf.sync("Build/createThirdPartyNpm");
    });
}

gulp.task("build", async function () {
  mkdirp.sync("Build");

  fs.writeFileSync(
    "Build/package.json",
    JSON.stringify({
      type: "commonjs",
    }),
    "utf8"
  );

  await buildThirdParty();
  glslToJavaScript(minifyShaders, "Build/minifyShaders.state");
  createCesiumJs();
  createSpecList();
  createJsHintOptions();
  return Promise.join(createWorkers(), createGalleryList());
});

gulp.task("build-watch", function () {
  return gulp.watch(watchedFiles, gulp.series("build"));
});

gulp.task("build-ts", function () {
  createTypeScriptDefinitions();
  return Promise.resolve();
});

gulp.task("buildApps", function () {
  return Promise.join(buildCesiumViewer(), buildSandcastle());
});

gulp.task("build-specs", function buildSpecs() {
  const externalCesium = rollupPluginExternalGlobals({
    "../Source/Cesium.js": "Cesium",
    "../../Source/Cesium.js": "Cesium",
    "../../../Source/Cesium.js": "Cesium",
    "../../../../Source/Cesium.js": "Cesium",
  });

  const removePragmas = rollupPluginStripPragma({
    pragmas: ["debug"],
  });

  const promise = Promise.join(
    rollup
      .rollup({
        input: "Specs/SpecList.js",
        plugins: [externalCesium],
        onwarn: rollupWarning,
      })
      .then(function (bundle) {
        return bundle.write({
          file: "Build/Specs/Specs.js",
          format: "iife",
        });
      })
      .then(function () {
        return rollup
          .rollup({
            input: "Specs/spec-main.js",
            plugins: [removePragmas, externalCesium],
          })
          .then(function (bundle) {
            return bundle.write({
              file: "Build/Specs/spec-main.js",
              format: "iife",
            });
          });
      })
      .then(function () {
        return rollup
          .rollup({
            input: "Specs/karma-main.js",
            plugins: [removePragmas, externalCesium],
            onwarn: rollupWarning,
          })
          .then(function (bundle) {
            return bundle.write({
              file: "Build/Specs/karma-main.js",
              name: "karmaMain",
              format: "iife",
            });
          });
      })
  );

  return promise;
});

gulp.task("clean", function (done) {
  rimraf.sync("Build");
  globby.sync(filesToClean).forEach(function (file) {
    rimraf.sync(file);
  });
  done();
});

function cloc() {
  let cmdLine;

  //Run cloc on primary Source files only
  const source = new Promise(function (resolve, reject) {
    cmdLine =
      "npx cloc" +
      " --quiet --progress-rate=0" +
      " Source/ --exclude-dir=Assets,ThirdParty,Workers --not-match-f=copyrightHeader.js";

    child_process.exec(cmdLine, function (error, stdout, stderr) {
      if (error) {
        console.log(stderr);
        return reject(error);
      }
      console.log("Source:");
      console.log(stdout);
      resolve();
    });
  });

  //If running cloc on source succeeded, also run it on the tests.
  return source.then(function () {
    return new Promise(function (resolve, reject) {
      cmdLine =
        "npx cloc" +
        " --quiet --progress-rate=0" +
        " Specs/ --exclude-dir=Data";
      child_process.exec(cmdLine, function (error, stdout, stderr) {
        if (error) {
          console.log(stderr);
          return reject(error);
        }
        console.log("Specs:");
        console.log(stdout);
        resolve();
      });
    });
  });
}

gulp.task("cloc", gulp.series("clean", cloc));

function combine() {
  const outputDirectory = path.join("Build", "CesiumUnminified");
  return combineJavaScript({
    removePragmas: false,
    optimizer: "none",
    outputDirectory: outputDirectory,
  });
}

gulp.task("combine", gulp.series("build", combine));
gulp.task("default", gulp.series("combine"));

function combineRelease() {
  const outputDirectory = path.join("Build", "CesiumUnminified");
  return combineJavaScript({
    removePragmas: true,
    optimizer: "none",
    outputDirectory: outputDirectory,
  });
}

gulp.task("combineRelease", gulp.series("build", combineRelease));

async function downloadAndWriteFile(url, path) {
  return new Promise(function (resolve, reject) {
    request(url)
      .pipe(fs.createWriteStream(path))
      .on("error", reject)
      .on("finish", resolve);
  });
}

// Downloads Draco3D files from gstatic servers
gulp.task("prepare", async function () {
  await downloadAndWriteFile(
    "https://www.gstatic.com/draco/versioned/decoders/1.3.5/draco_wasm_wrapper.js",
    "Source/ThirdParty/Workers/draco_wasm_wrapper.js"
  );
  await downloadAndWriteFile(
    "https://www.gstatic.com/draco/versioned/decoders/1.3.5/draco_decoder.wasm",
    "Source/ThirdParty/draco_decoder.wasm"
  );
});

//Builds the documentation
function generateDocumentation() {
  child_process.execSync("npx jsdoc --configure Tools/jsdoc/conf.json", {
    stdio: "inherit",
    env: Object.assign({}, process.env, { CESIUM_VERSION: version }),
  });

  const stream = gulp
    .src("Documentation/Images/**")
    .pipe(gulp.dest("Build/Documentation/Images"));

  return streamToPromise(stream);
}
gulp.task("generateDocumentation", generateDocumentation);

gulp.task("generateDocumentation-watch", function () {
  return generateDocumentation().done(function () {
    console.log("Listening for changes in documentation...");
    return gulp.watch(sourceFiles, gulp.series("generateDocumentation"));
  });
});

gulp.task(
  "release",
  gulp.series(
    "build",
    "build-ts",
    combine,
    minifyRelease,
    generateDocumentation
  )
);

gulp.task(
  "makeZipFile",
  gulp.series("release", function () {
    //For now we regenerate the JS glsl to force it to be unminified in the release zip
    //See https://github.com/CesiumGS/cesium/pull/3106#discussion_r42793558 for discussion.
    glslToJavaScript(false, "Build/minifyShaders.state");

    // Remove prepare step from package.json to avoid redownloading Draco3d files
    delete packageJson.scripts.prepare;
    fs.writeFileSync(
      "./Build/package.noprepare.json",
      JSON.stringify(packageJson, null, 2)
    );

    const packageJsonSrc = gulp
      .src("Build/package.noprepare.json")
      .pipe(gulpRename("package.json"));

    const builtSrc = gulp.src(
      [
        "Build/Cesium/**",
        "Build/CesiumUnminified/**",
        "Build/Documentation/**",
        "Build/package.json",
      ],
      {
        base: ".",
      }
    );

    const staticSrc = gulp.src(
      [
        "Apps/**",
        "!Apps/Sandcastle/gallery/development/**",
        "Source/**",
        "Specs/**",
        "ThirdParty/**",
        "favicon.ico",
        "gulpfile.cjs",
        "server.cjs",
        "index.cjs",
        "LICENSE.md",
        "CHANGES.md",
        "README.md",
        "web.config",
      ],
      {
        base: ".",
      }
    );

    const indexSrc = gulp
      .src("index.release.html")
      .pipe(gulpRename("index.html"));

    return mergeStream(packageJsonSrc, builtSrc, staticSrc, indexSrc)
      .pipe(
        gulpTap(function (file) {
          // Work around an issue with gulp-zip where archives generated on Windows do
          // not properly have their directory executable mode set.
          // see https://github.com/sindresorhus/gulp-zip/issues/64#issuecomment-205324031
          if (file.isDirectory()) {
            file.stat.mode = parseInt("40777", 8);
          }
        })
      )
      .pipe(gulpZip("Cesium-" + version + ".zip"))
      .pipe(gulp.dest("."))
      .on("finish", function () {
        rimraf.sync("./Build/package.noprepare.json");
      });
  })
);

gulp.task(
  "minify",
  gulp.series("build", function () {
    return combineJavaScript({
      removePragmas: false,
      optimizer: "uglify2",
      outputDirectory: path.join("Build", "Cesium"),
    });
  })
);

function minifyRelease() {
  return combineJavaScript({
    removePragmas: true,
    optimizer: "uglify2",
    outputDirectory: path.join("Build", "Cesium"),
  });
}

gulp.task("minifyRelease", gulp.series("build", minifyRelease));

function isTravisPullRequest() {
  return (
    process.env.TRAVIS_PULL_REQUEST !== undefined &&
    process.env.TRAVIS_PULL_REQUEST !== "false"
  );
}

gulp.task("deploy-s3", function (done) {
  if (isTravisPullRequest()) {
    console.log("Skipping deployment for non-pull request.");
    done();
    return;
  }

  const argv = yargs
    .usage("Usage: deploy-s3 -b [Bucket Name] -d [Upload Directory]")
    .demand(["b", "d"]).argv;

  const uploadDirectory = argv.d;
  const bucketName = argv.b;
  const cacheControl = argv.c ? argv.c : "max-age=3600";

  if (argv.confirm) {
    // skip prompt for travis
    deployCesium(bucketName, uploadDirectory, cacheControl, done);
    return;
  }

  const iface = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  // prompt for confirmation
  iface.question(
    "Files from your computer will be published to the " +
      bucketName +
      " bucket. Continue? [y/n] ",
    function (answer) {
      iface.close();
      if (answer === "y") {
        deployCesium(bucketName, uploadDirectory, cacheControl, done);
      } else {
        console.log("Deploy aborted by user.");
        done();
      }
    }
  );
});

// Deploy cesium to s3
function deployCesium(bucketName, uploadDirectory, cacheControl, done) {
  const readFile = Promise.promisify(fs.readFile);
  const gzip = Promise.promisify(zlib.gzip);
  const concurrencyLimit = 2000;

  const s3 = new AWS.S3({
    maxRetries: 10,
    retryDelayOptions: {
      base: 500,
    },
  });

  const existingBlobs = [];
  let totalFiles = 0;
  let uploaded = 0;
  let skipped = 0;
  const errors = [];

  const prefix = uploadDirectory + "/";
  return listAll(s3, bucketName, prefix, existingBlobs)
    .then(function () {
      return globby(
        [
          "Apps/**",
          "Build/**",
          "Source/**",
          "Specs/**",
          "ThirdParty/**",
          "*.md",
          "favicon.ico",
          "gulpfile.cjs",
          "index.html",
          "package.json",
          "server.cjs",
          "web.config",
          "*.zip",
          "*.tgz",
        ],
        {
          dot: true, // include hidden files
        }
      );
    })
    .then(function (files) {
      return Promise.map(
        files,
        function (file) {
          const blobName = uploadDirectory + "/" + file;
          const mimeLookup = getMimeType(blobName);
          const contentType = mimeLookup.type;
          const compress = mimeLookup.compress;
          const contentEncoding = compress ? "gzip" : undefined;
          let etag;

          totalFiles++;

          return readFile(file)
            .then(function (content) {
              if (!compress) {
                return content;
              }

              const alreadyCompressed =
                content[0] === 0x1f && content[1] === 0x8b;
              if (alreadyCompressed) {
                console.log(
                  "Skipping compressing already compressed file: " + file
                );
                return content;
              }

              return gzip(content);
            })
            .then(function (content) {
              // compute hash and etag
              const hash = crypto
                .createHash("md5")
                .update(content)
                .digest("hex");
              etag = crypto.createHash("md5").update(content).digest("base64");

              const index = existingBlobs.indexOf(blobName);
              if (index <= -1) {
                return content;
              }

              // remove files as we find them on disk
              existingBlobs.splice(index, 1);

              // get file info
              return s3
                .headObject({
                  Bucket: bucketName,
                  Key: blobName,
                })
                .promise()
                .then(function (data) {
                  if (
                    data.ETag !== '"' + hash + '"' ||
                    data.CacheControl !== cacheControl ||
                    data.ContentType !== contentType ||
                    data.ContentEncoding !== contentEncoding
                  ) {
                    return content;
                  }

                  // We don't need to upload this file again
                  skipped++;
                  return undefined;
                })
                .catch(function (error) {
                  errors.push(error);
                });
            })
            .then(function (content) {
              if (!content) {
                return;
              }

              if (verbose) {
                console.log("Uploading " + blobName + "...");
              }
              const params = {
                Bucket: bucketName,
                Key: blobName,
                Body: content,
                ContentMD5: etag,
                ContentType: contentType,
                ContentEncoding: contentEncoding,
                CacheControl: cacheControl,
              };

              return s3
                .putObject(params)
                .promise()
                .then(function () {
                  uploaded++;
                })
                .catch(function (error) {
                  errors.push(error);
                });
            });
        },
        { concurrency: concurrencyLimit }
      );
    })
    .then(function () {
      console.log(
        "Skipped " +
          skipped +
          " files and successfully uploaded " +
          uploaded +
          " files of " +
          (totalFiles - skipped) +
          " files."
      );
      if (existingBlobs.length === 0) {
        return;
      }

      const objectsToDelete = [];
      existingBlobs.forEach(function (file) {
        //Don't delete generate zip files.
        if (!/\.(zip|tgz)$/.test(file)) {
          objectsToDelete.push({ Key: file });
        }
      });

      if (objectsToDelete.length > 0) {
        console.log("Cleaning " + objectsToDelete.length + " files...");

        // If more than 1000 files, we must issue multiple requests
        const batches = [];
        while (objectsToDelete.length > 1000) {
          batches.push(objectsToDelete.splice(0, 1000));
        }
        batches.push(objectsToDelete);

        return Promise.map(
          batches,
          function (objects) {
            return s3
              .deleteObjects({
                Bucket: bucketName,
                Delete: {
                  Objects: objects,
                },
              })
              .promise()
              .then(function () {
                if (verbose) {
                  console.log("Cleaned " + objects.length + " files.");
                }
              });
          },
          { concurrency: concurrency }
        );
      }
    })
    .catch(function (error) {
      errors.push(error);
    })
    .then(function () {
      if (errors.length === 0) {
        done();
        return;
      }

      console.log("Errors: ");
      errors.map(function (e) {
        console.log(e);
      });
      done(1);
    });
}

function getMimeType(filename) {
  const mimeType = mime.getType(filename);
  if (mimeType) {
    //Compress everything except zipfiles, binary images, and video
    let compress = !/^(image\/|video\/|application\/zip|application\/gzip)/i.test(
      mimeType
    );
    if (mimeType === "image/svg+xml") {
      compress = true;
    }
    return { type: mimeType, compress: compress };
  }

  //Non-standard mime types not handled by mime
  if (/\.(glsl|LICENSE|config|state)$/i.test(filename)) {
    return { type: "text/plain", compress: true };
  } else if (/\.(czml|topojson)$/i.test(filename)) {
    return { type: "application/json", compress: true };
  } else if (/\.tgz$/i.test(filename)) {
    return { type: "application/octet-stream", compress: false };
  }

  // Handle dotfiles, such as .jshintrc
  const baseName = path.basename(filename);
  if (baseName[0] === "." || baseName.indexOf(".") === -1) {
    return { type: "text/plain", compress: true };
  }

  // Everything else can be octet-stream compressed but print a warning
  // if we introduce a type we aren't specifically handling.
  if (!/\.(terrain|b3dm|geom|pnts|vctr|cmpt|i3dm|metadata)$/i.test(filename)) {
    console.log("Unknown mime type for " + filename);
  }

  return { type: "application/octet-stream", compress: true };
}

// get all files currently in bucket asynchronously
function listAll(s3, bucketName, prefix, files, marker) {
  return s3
    .listObjects({
      Bucket: bucketName,
      MaxKeys: 1000,
      Prefix: prefix,
      Marker: marker,
    })
    .promise()
    .then(function (data) {
      const items = data.Contents;
      for (let i = 0; i < items.length; i++) {
        files.push(items[i].Key);
      }

      if (data.IsTruncated) {
        // get next page of results
        return listAll(s3, bucketName, prefix, files, files[files.length - 1]);
      }
    });
}

gulp.task("deploy-set-version", function (done) {
  const buildVersion = yargs.argv.buildVersion;
  if (buildVersion) {
    // NPM versions can only contain alphanumeric and hyphen characters
    packageJson.version += "-" + buildVersion.replace(/[^[0-9A-Za-z-]/g, "");
    fs.writeFileSync("package.json", JSON.stringify(packageJson, undefined, 2));
  }
  done();
});

gulp.task("deploy-status", function () {
  if (isTravisPullRequest()) {
    console.log("Skipping deployment status for non-pull request.");
    return Promise.resolve();
  }

  const status = yargs.argv.status;
  const message = yargs.argv.message;

  const deployUrl = travisDeployUrl + process.env.TRAVIS_BRANCH + "/";
  const zipUrl = deployUrl + "Cesium-" + packageJson.version + ".zip";
  const npmUrl = deployUrl + "cesium-" + packageJson.version + ".tgz";
  const coverageUrl =
    travisDeployUrl + process.env.TRAVIS_BRANCH + "/Build/Coverage/index.html";

  return Promise.join(
    setStatus(status, deployUrl, message, "deployment"),
    setStatus(status, zipUrl, message, "zip file"),
    setStatus(status, npmUrl, message, "npm package"),
    setStatus(status, coverageUrl, message, "coverage results")
  );
});

function setStatus(state, targetUrl, description, context) {
  // skip if the environment does not have the token
  if (!process.env.TOKEN) {
    return;
  }

  const requestPost = Promise.promisify(request.post);
  return requestPost({
    url:
      "https://api.github.com/repos/" +
      process.env.TRAVIS_REPO_SLUG +
      "/statuses/" +
      process.env.TRAVIS_COMMIT,
    json: true,
    headers: {
      Authorization: "token " + process.env.TOKEN,
      "User-Agent": "Cesium",
    },
    body: {
      state: state,
      target_url: targetUrl,
      description: description,
      context: context,
    },
  });
}

gulp.task("coverage", function (done) {
  const argv = yargs.argv;
  const webglStub = argv.webglStub ? argv.webglStub : false;
  const suppressPassed = argv.suppressPassed ? argv.suppressPassed : false;
  const failTaskOnError = argv.failTaskOnError ? argv.failTaskOnError : false;

  const folders = [];
  let browsers = ["Chrome"];
  if (argv.browsers) {
    browsers = argv.browsers.split(",");
  }

  const karma = new Karma.Server(
    {
      configFile: karmaConfigFile,
      browsers: browsers,
      specReporter: {
        suppressErrorSummary: false,
        suppressFailed: false,
        suppressPassed: suppressPassed,
        suppressSkipped: true,
      },
      preprocessors: {
        "Source/Core/**/*.js": ["karma-coverage-istanbul-instrumenter"],
        "Source/DataSources/**/*.js": ["karma-coverage-istanbul-instrumenter"],
        "Source/Renderer/**/*.js": ["karma-coverage-istanbul-instrumenter"],
        "Source/Scene/**/*.js": ["karma-coverage-istanbul-instrumenter"],
        "Source/Shaders/**/*.js": ["karma-coverage-istanbul-instrumenter"],
        "Source/Widgets/**/*.js": ["karma-coverage-istanbul-instrumenter"],
        "Source/Workers/**/*.js": ["karma-coverage-istanbul-instrumenter"],
      },
      coverageIstanbulInstrumenter: {
        esModules: true,
      },
      reporters: ["spec", "coverage"],
      coverageReporter: {
        dir: "Build/Coverage",
        subdir: function (browserName) {
          folders.push(browserName);
          return browserName;
        },
        includeAllSources: true,
      },
      client: {
        captureConsole: verbose,
        args: [undefined, undefined, undefined, webglStub, undefined],
      },
    },
    function (e) {
      let html = "<!doctype html><html><body><ul>";
      folders.forEach(function (folder) {
        html +=
          '<li><a href="' +
          encodeURIComponent(folder) +
          '/index.html">' +
          folder +
          "</a></li>";
      });
      html += "</ul></body></html>";
      fs.writeFileSync("Build/Coverage/index.html", html);

      if (!process.env.TRAVIS) {
        folders.forEach(function (dir) {
          open("Build/Coverage/" + dir + "/index.html");
        });
      }
      return done(failTaskOnError ? e : undefined);
    }
  );
  karma.start();
});

gulp.task("test", function (done) {
  const argv = yargs.argv;

  const enableAllBrowsers = argv.all ? true : false;
  const includeCategory = argv.include ? argv.include : "";
  const excludeCategory = argv.exclude ? argv.exclude : "";
  const webglValidation = argv.webglValidation ? argv.webglValidation : false;
  const webglStub = argv.webglStub ? argv.webglStub : false;
  const release = argv.release ? argv.release : false;
  const failTaskOnError = argv.failTaskOnError ? argv.failTaskOnError : false;
  const suppressPassed = argv.suppressPassed ? argv.suppressPassed : false;

  let browsers = ["Chrome"];
  if (argv.browsers) {
    browsers = argv.browsers.split(",");
  }

  let files = [
    { pattern: "Specs/karma-main.js", included: true, type: "module" },
    { pattern: "Source/**", included: false, type: "module" },
    { pattern: "Specs/*.js", included: true, type: "module" },
    { pattern: "Specs/Core/**", included: true, type: "module" },
    { pattern: "Specs/Data/**", included: false },
    { pattern: "Specs/DataSources/**", included: true, type: "module" },
    { pattern: "Specs/Renderer/**", included: true, type: "module" },
    { pattern: "Specs/Scene/**", included: true, type: "module" },
    { pattern: "Specs/ThirdParty/**", included: true, type: "module" },
    { pattern: "Specs/Widgets/**", included: true, type: "module" },
    { pattern: "Specs/TestWorkers/**", included: false },
  ];

  if (release) {
    files = [
      { pattern: "Specs/Data/**", included: false },
      { pattern: "Specs/ThirdParty/**", included: true, type: "module" },
      { pattern: "Specs/TestWorkers/**", included: false },
      { pattern: "Build/Cesium/Cesium.js", included: true },
      { pattern: "Build/Cesium/**", included: false },
      { pattern: "Build/Specs/karma-main.js", included: true },
      { pattern: "Build/Specs/Specs.js", included: true },
    ];
  }

  const karma = new Karma.Server(
    {
      configFile: karmaConfigFile,
      browsers: browsers,
      specReporter: {
        suppressErrorSummary: false,
        suppressFailed: false,
        suppressPassed: suppressPassed,
        suppressSkipped: true,
      },
      detectBrowsers: {
        enabled: enableAllBrowsers,
      },
      logLevel: verbose ? Karma.constants.LOG_INFO : Karma.constants.LOG_ERROR,
      files: files,
      client: {
        captureConsole: verbose,
        args: [
          includeCategory,
          excludeCategory,
          webglValidation,
          webglStub,
          release,
        ],
      },
    },
    function (e) {
      return done(failTaskOnError ? e : undefined);
    }
  );
  karma.start();
});

gulp.task("convertToModules", function () {
  const requiresRegex = /([\s\S]*?(define|defineSuite|require)\((?:{[\s\S]*}, )?\[)([\S\s]*?)]([\s\S]*?function\s*)\(([\S\s]*?)\) {([\s\S]*)/;
  const noModulesRegex = /([\s\S]*?(define|defineSuite|require)\((?:{[\s\S]*}, )?\[?)([\S\s]*?)]?([\s\S]*?function\s*)\(([\S\s]*?)\) {([\s\S]*)/;
  const splitRegex = /,\s*/;

  const fsReadFile = Promise.promisify(fs.readFile);
  const fsWriteFile = Promise.promisify(fs.writeFile);

  const files = globby.sync(filesToConvertES6);

  return Promise.map(files, function (file) {
    return fsReadFile(file).then(function (contents) {
      contents = contents.toString();
      if (contents.startsWith("import")) {
        return;
      }

      let result = requiresRegex.exec(contents);

      if (result === null) {
        result = noModulesRegex.exec(contents);
        if (result === null) {
          return;
        }
      }

      const names = result[3].split(splitRegex);
      if (names.length === 1 && names[0].trim() === "") {
        names.length = 0;
      }

      for (let i = 0; i < names.length; ++i) {
        if (names[i].indexOf("//") >= 0 || names[i].indexOf("/*") >= 0) {
          console.log(
            file +
              " contains comments in the require list.  Skipping so nothing gets broken."
          );
          return;
        }
      }

      const identifiers = result[5].split(splitRegex);
      if (identifiers.length === 1 && identifiers[0].trim() === "") {
        identifiers.length = 0;
      }

      for (let i = 0; i < identifiers.length; ++i) {
        if (
          identifiers[i].indexOf("//") >= 0 ||
          identifiers[i].indexOf("/*") >= 0
        ) {
          console.log(
            file +
              " contains comments in the require list.  Skipping so nothing gets broken."
          );
          return;
        }
      }

      const requires = [];

      for (let i = 0; i < names.length && i < identifiers.length; ++i) {
        requires.push({
          name: names[i].trim(),
          identifier: identifiers[i].trim(),
        });
      }

      // Convert back to separate lists for the names and identifiers, and add
      // any additional names or identifiers that don't have a corresponding pair.
      const sortedNames = requires.map(function (item) {
        return item.name.slice(0, -1) + ".js'";
      });
      for (let i = sortedNames.length; i < names.length; ++i) {
        sortedNames.push(names[i].trim());
      }

      const sortedIdentifiers = requires.map(function (item) {
        return item.identifier;
      });
      for (let i = sortedIdentifiers.length; i < identifiers.length; ++i) {
        sortedIdentifiers.push(identifiers[i].trim());
      }

      contents = "";
      if (sortedNames.length > 0) {
        for (let q = 0; q < sortedNames.length; q++) {
          let modulePath = sortedNames[q];
          if (file.startsWith("Specs")) {
            modulePath = modulePath.substring(1, modulePath.length - 1);
            const sourceDir = path.dirname(file);

            if (modulePath.startsWith("Specs") || modulePath.startsWith(".")) {
              let importPath = modulePath;
              if (modulePath.startsWith("Specs")) {
                importPath = path.relative(sourceDir, modulePath);
                if (importPath[0] !== ".") {
                  importPath = "./" + importPath;
                }
              }
              modulePath = "'" + importPath + "'";
              contents +=
                "import " +
                sortedIdentifiers[q] +
                " from " +
                modulePath +
                ";" +
                os.EOL;
            } else {
              modulePath =
                "'" + path.relative(sourceDir, "Source") + "/Cesium.js" + "'";
              if (sortedIdentifiers[q] === "CesiumMath") {
                contents +=
                  "import { Math as CesiumMath } from " +
                  modulePath +
                  ";" +
                  os.EOL;
              } else {
                contents +=
                  "import { " +
                  sortedIdentifiers[q] +
                  " } from " +
                  modulePath +
                  ";" +
                  os.EOL;
              }
            }
          } else {
            contents +=
              "import " +
              sortedIdentifiers[q] +
              " from " +
              modulePath +
              ";" +
              os.EOL;
          }
        }
      }

      let code;
      const codeAndReturn = result[6];
      if (file.endsWith("Spec.js")) {
        const indi = codeAndReturn.lastIndexOf("});");
        code = codeAndReturn.slice(0, indi);
        code = code.trim().replace("'use strict';" + os.EOL, "");
        contents += code + os.EOL;
      } else {
        const returnIndex = codeAndReturn.lastIndexOf("return");

        code = codeAndReturn.slice(0, returnIndex);
        code = code.trim().replace("'use strict';" + os.EOL, "");
        contents += code + os.EOL;

        const returnStatement = codeAndReturn.slice(returnIndex);
        contents +=
          returnStatement.split(";")[0].replace("return ", "export default ") +
          ";" +
          os.EOL;
      }

      return fsWriteFile(file, contents);
    });
  });
});

function combineCesium(debug, optimizer, combineOutput) {
  const plugins = [];

  if (!debug) {
    plugins.push(
      rollupPluginStripPragma({
        pragmas: ["debug"],
      })
    );
  }
  if (optimizer === "uglify2") {
    plugins.push(rollupPluginUglify.uglify());
  }

  return rollup
    .rollup({
      input: "Source/Cesium.js",
      plugins: plugins,
      onwarn: rollupWarning,
    })
    .then(function (bundle) {
      return bundle.write({
        format: "umd",
        name: "Cesium",
        file: path.join(combineOutput, "Cesium.js"),
        sourcemap: debug,
        banner: copyrightHeader,
      });
    });
}

function combineWorkers(debug, optimizer, combineOutput) {
  //This is done waterfall style for concurrency reasons.
  // Copy files that are already minified
  return globby(["Source/ThirdParty/Workers/draco*.js"])
    .then(function (files) {
      const stream = gulp
        .src(files, { base: "Source" })
        .pipe(gulp.dest(combineOutput));
      return streamToPromise(stream);
    })
    .then(function () {
      return globby([
        "Source/Workers/cesiumWorkerBootstrapper.js",
        "Source/Workers/transferTypedArrayTest.js",
        "Source/ThirdParty/Workers/*.js",
        // Files are already minified, don't optimize
        "!Source/ThirdParty/Workers/draco*.js",
      ]);
    })
    .then(function (files) {
      return Promise.map(
        files,
        function (file) {
          return streamToPromise(
            gulp
              .src(file)
              .pipe(gulpUglify())
              .pipe(
                gulp.dest(
                  path.dirname(
                    path.join(combineOutput, path.relative("Source", file))
                  )
                )
              )
          );
        },
        { concurrency: concurrency }
      );
    })
    .then(function () {
      return globby(["Source/WorkersES6/*.js"]);
    })
    .then(function (files) {
      const plugins = [];

      if (!debug) {
        plugins.push(
          rollupPluginStripPragma({
            pragmas: ["debug"],
          })
        );
      }
      if (optimizer === "uglify2") {
        plugins.push(rollupPluginUglify.uglify());
      }

      return rollup
        .rollup({
          input: files,
          plugins: plugins,
          onwarn: rollupWarning,
        })
        .then(function (bundle) {
          return bundle.write({
            dir: path.join(combineOutput, "Workers"),
            format: "amd",
            sourcemap: debug,
            banner: copyrightHeader,
          });
        });
    });
}

function minifyCSS(outputDirectory) {
  streamToPromise(
    gulp
      .src("Source/**/*.css")
      .pipe(cleanCSS())
      .pipe(gulp.dest(outputDirectory))
  );
}

function minifyModules(outputDirectory) {
  return streamToPromise(
    gulp
      .src("Source/ThirdParty/google-earth-dbroot-parser.js")
      .pipe(gulpUglify())
      .pipe(gulp.dest(outputDirectory + "/ThirdParty/"))
  );
}

function combineJavaScript(options) {
  const optimizer = options.optimizer;
  const outputDirectory = options.outputDirectory;
  const removePragmas = options.removePragmas;

  const combineOutput = path.join("Build", "combineOutput", optimizer);

  const promise = Promise.join(
    combineCesium(!removePragmas, optimizer, combineOutput),
    combineWorkers(!removePragmas, optimizer, combineOutput),
    minifyModules(outputDirectory)
  );

  return promise.then(function () {
    const promises = [];

    //copy to build folder with copyright header added at the top
    let stream = gulp
      .src([combineOutput + "/**"])
      .pipe(gulp.dest(outputDirectory));

    promises.push(streamToPromise(stream));

    const everythingElse = ["Source/**", "!**/*.js", "!**/*.glsl"];
    if (optimizer === "uglify2") {
      promises.push(minifyCSS(outputDirectory));
      everythingElse.push("!**/*.css");
    }

    stream = gulp
      .src(everythingElse, { nodir: true })
      .pipe(gulp.dest(outputDirectory));
    promises.push(streamToPromise(stream));

    return Promise.all(promises).then(function () {
      rimraf.sync(combineOutput);
    });
  });
}

function glslToJavaScript(minify, minifyStateFilePath) {
  fs.writeFileSync(minifyStateFilePath, minify.toString());
  const minifyStateFileLastModified = fs.existsSync(minifyStateFilePath)
    ? fs.statSync(minifyStateFilePath).mtime.getTime()
    : 0;

  // collect all currently existing JS files into a set, later we will remove the ones
  // we still are using from the set, then delete any files remaining in the set.
  const leftOverJsFiles = {};

  globby
    .sync(["Source/Shaders/**/*.js", "Source/ThirdParty/Shaders/*.js"])
    .forEach(function (file) {
      leftOverJsFiles[path.normalize(file)] = true;
    });

  const builtinFunctions = [];
  const builtinConstants = [];
  const builtinStructs = [];

  const glslFiles = globby.sync([
    "Source/Shaders/**/*.glsl",
    "Source/ThirdParty/Shaders/*.glsl",
  ]);
  glslFiles.forEach(function (glslFile) {
    glslFile = path.normalize(glslFile);
    const baseName = path.basename(glslFile, ".glsl");
    const jsFile = path.join(path.dirname(glslFile), baseName) + ".js";

    // identify built in functions, structs, and constants
    const baseDir = path.join("Source", "Shaders", "Builtin");
    if (
      glslFile.indexOf(path.normalize(path.join(baseDir, "Functions"))) === 0
    ) {
      builtinFunctions.push(baseName);
    } else if (
      glslFile.indexOf(path.normalize(path.join(baseDir, "Constants"))) === 0
    ) {
      builtinConstants.push(baseName);
    } else if (
      glslFile.indexOf(path.normalize(path.join(baseDir, "Structs"))) === 0
    ) {
      builtinStructs.push(baseName);
    }

    delete leftOverJsFiles[jsFile];

    const jsFileExists = fs.existsSync(jsFile);
    const jsFileModified = jsFileExists
      ? fs.statSync(jsFile).mtime.getTime()
      : 0;
    const glslFileModified = fs.statSync(glslFile).mtime.getTime();

    if (
      jsFileExists &&
      jsFileModified > glslFileModified &&
      jsFileModified > minifyStateFileLastModified
    ) {
      return;
    }

    let contents = fs.readFileSync(glslFile, "utf8");
    contents = contents.replace(/\r\n/gm, "\n");

    let copyrightComments = "";
    const extractedCopyrightComments = contents.match(
      /\/\*\*(?:[^*\/]|\*(?!\/)|\n)*?@license(?:.|\n)*?\*\//gm
    );
    if (extractedCopyrightComments) {
      copyrightComments = extractedCopyrightComments.join("\n") + "\n";
    }

    if (minify) {
      contents = glslStripComments(contents);
      contents = contents
        .replace(/\s+$/gm, "")
        .replace(/^\s+/gm, "")
        .replace(/\n+/gm, "\n");
      contents += "\n";
    }

    contents = contents.split('"').join('\\"').replace(/\n/gm, "\\n\\\n");
    contents =
      copyrightComments +
      '\
//This file is automatically rebuilt by the Cesium build process.\n\
export default "' +
      contents +
      '";\n';

    fs.writeFileSync(jsFile, contents);
  });

  // delete any left over JS files from old shaders
  Object.keys(leftOverJsFiles).forEach(function (filepath) {
    rimraf.sync(filepath);
  });

  const generateBuiltinContents = function (contents, builtins, path) {
    for (let i = 0; i < builtins.length; i++) {
      const builtin = builtins[i];
      contents.imports.push(
        "import czm_" + builtin + " from './" + path + "/" + builtin + ".js'"
      );
      contents.builtinLookup.push("czm_" + builtin + " : " + "czm_" + builtin);
    }
  };

  //generate the JS file for Built-in GLSL Functions, Structs, and Constants
  const contents = {
    imports: [],
    builtinLookup: [],
  };
  generateBuiltinContents(contents, builtinConstants, "Constants");
  generateBuiltinContents(contents, builtinStructs, "Structs");
  generateBuiltinContents(contents, builtinFunctions, "Functions");

  const fileContents =
    "//This file is automatically rebuilt by the Cesium build process.\n" +
    contents.imports.join("\n") +
    "\n\nexport default {\n    " +
    contents.builtinLookup.join(",\n    ") +
    "\n};\n";

  fs.writeFileSync(
    path.join("Source", "Shaders", "Builtin", "CzmBuiltins.js"),
    fileContents
  );
}

function createCesiumJs() {
  let contents = `export var VERSION = '${version}';\n`;
  globby.sync(sourceFiles).forEach(function (file) {
    file = path.relative("Source", file);

    let moduleId = file;
    moduleId = filePathToModuleId(moduleId);

    let assignmentName = path.basename(file, path.extname(file));
    if (moduleId.indexOf("Shaders/") === 0) {
      assignmentName = "_shaders" + assignmentName;
    }
    assignmentName = assignmentName.replace(/(\.|-)/g, "_");
    contents +=
      "export { default as " +
      assignmentName +
      " } from './" +
      moduleId +
      ".js';" +
      os.EOL;
  });

  fs.writeFileSync("Source/Cesium.js", contents);
}

function createTypeScriptDefinitions() {
  // Run jsdoc with tsd-jsdoc to generate an initial Cesium.d.ts file.
  child_process.execSync("npx jsdoc --configure Tools/jsdoc/ts-conf.json", {
    stdio: "inherit",
  });

  let source = fs.readFileSync("Source/Cesium.d.ts").toString();

  // All of our enum assignments that alias to WebGLConstants, such as PixelDatatype.js
  // end up as enum strings instead of actually mapping values to WebGLConstants.
  // We fix this with a simple regex replace later on, but it means the
  // WebGLConstants constants enum needs to be defined in the file before it can
  // be used.  This block of code reads in the TS file, finds the WebGLConstants
  // declaration, and then writes the file back out (in memory to source) with
  // WebGLConstants being the first module.
  const node = typescript.createSourceFile(
    "Source/Cesium.d.ts",
    source,
    typescript.ScriptTarget.Latest
  );
  let firstNode;
  node.forEachChild((child) => {
    if (
      typescript.SyntaxKind[child.kind] === "EnumDeclaration" &&
      child.name.escapedText === "WebGLConstants"
    ) {
      firstNode = child;
    }
  });

  const printer = typescript.createPrinter({
    removeComments: false,
    newLine: typescript.NewLineKind.LineFeed,
  });

  let newSource = "";
  newSource += printer.printNode(
    typescript.EmitHint.Unspecified,
    firstNode,
    node
  );
  newSource += "\n\n";
  node.forEachChild((child) => {
    if (
      typescript.SyntaxKind[child.kind] !== "EnumDeclaration" ||
      child.name.escapedText !== "WebGLConstants"
    ) {
      newSource += printer.printNode(
        typescript.EmitHint.Unspecified,
        child,
        node
      );
      newSource += "\n\n";
    }
  });
  source = newSource;

  // The next step is to find the list of Cesium modules exported by the Cesium API
  // So that we can map these modules with a link back to their original source file.

  const regex = /^declare (function|class|namespace|enum) (.+)/gm;
  let matches;
  const publicModules = new Set();
  //eslint-disable-next-line no-cond-assign
  while ((matches = regex.exec(source))) {
    const moduleName = matches[2].match(/([^\s|\(]+)/);
    publicModules.add(moduleName[1]);
  }

  // Math shows up as "Math" because of it's aliasing from CesiumMath and namespace collision with actual Math
  // It fails the above regex so just add it directly here.
  publicModules.add("Math");

  // Fix up the output to match what we need
  // declare => export since we are wrapping everything in a namespace
  // CesiumMath => Math (because no CesiumJS build step would be complete without special logic for the Math class)
  // Fix up the WebGLConstants aliasing we mentioned above by simply unquoting the strings.
  source = source
    .replace(/^declare /gm, "export ")
    .replace(/module "Math"/gm, "namespace Math")
    .replace(/CesiumMath/gm, "Math")
    .replace(/Number\[]/gm, "number[]") // Workaround https://github.com/englercj/tsd-jsdoc/issues/117
    .replace(/String\[]/gm, "string[]")
    .replace(/Boolean\[]/gm, "boolean[]")
    .replace(/Object\[]/gm, "object[]")
    .replace(/<Number>/gm, "<number>")
    .replace(/<String>/gm, "<string>")
    .replace(/<Boolean>/gm, "<boolean>")
    .replace(/<Object>/gm, "<object>")
    .replace(
      /= "WebGLConstants\.(.+)"/gm,
      // eslint-disable-next-line no-unused-vars
      (match, p1) => `= WebGLConstants.${p1}`
    );

  // Wrap the source to actually be inside of a declared cesium module
  // and add any workaround and private utility types.
  source = `declare module "cesium" {

/**
 * Private interfaces to support PropertyBag being a dictionary-like object.
 */
interface DictionaryLike {
    [index: string]: any;
}

${source}
}

`;

  // Map individual modules back to their source file so that TS still works
  // when importing individual files instead of the entire cesium module.
  globby.sync(sourceFiles).forEach(function (file) {
    file = path.relative("Source", file);

    let moduleId = file;
    moduleId = filePathToModuleId(moduleId);

    const assignmentName = path.basename(file, path.extname(file));
    if (publicModules.has(assignmentName)) {
      publicModules.delete(assignmentName);
      source += `declare module "cesium/Source/${moduleId}" { import { ${assignmentName} } from 'cesium'; export default ${assignmentName}; }\n`;
    }
  });

  // Write the final source file back out
  fs.writeFileSync("Source/Cesium.d.ts", source);

  // Use tsc to compile it and make sure it is valid
  child_process.execSync("npx tsc -p Tools/jsdoc/tsconfig.json", {
    stdio: "inherit",
  });

  // Also compile our smokescreen to make sure interfaces work as expected.
  child_process.execSync("npx tsc -p Specs/TypeScript/tsconfig.json", {
    stdio: "inherit",
  });

  // Below is a sanity check to make sure we didn't leave anything out that
  // we don't already know about

  // Intentionally ignored nested items
  publicModules.delete("KmlFeatureData");
  publicModules.delete("MaterialAppearance");

  if (publicModules.size !== 0) {
    throw new Error(
      "Unexpected unexposed modules: " +
        Array.from(publicModules.values()).join(", ")
    );
  }
}

function createSpecList() {
  const specFiles = globby.sync(["Specs/**/*Spec.js"]);

  let contents = "";
  specFiles.forEach(function (file) {
    contents +=
      "import './" + filePathToModuleId(file).replace("Specs/", "") + ".js';\n";
  });

  fs.writeFileSync(path.join("Specs", "SpecList.js"), contents);
}

function createGalleryList() {
  const demoObjects = [];
  const demoJSONs = [];
  const output = path.join("Apps", "Sandcastle", "gallery", "gallery-index.js");

  const fileList = ["Apps/Sandcastle/gallery/**/*.html"];
  if (noDevelopmentGallery) {
    fileList.push("!Apps/Sandcastle/gallery/development/**/*.html");
  }

  // On travis, the version is set to something like '1.43.0-branch-name-travisBuildNumber'
  // We need to extract just the Major.Minor version
  const majorMinor = packageJson.version.match(/^(.*)\.(.*)\./);
  const major = majorMinor[1];
  const minor = Number(majorMinor[2]) - 1; // We want the last release, not current release
  const tagVersion = major + "." + minor;

  // Get an array of demos that were added since the last release.
  // This includes newly staged local demos as well.
  let newDemos = [];
  try {
    newDemos = child_process
      .execSync(
        "git diff --name-only --diff-filter=A " +
          tagVersion +
          " Apps/Sandcastle/gallery/*.html",
        { stdio: ["pipe", "pipe", "ignore"] }
      )
      .toString()
      .trim()
      .split("\n");
  } catch (e) {
    // On a Cesium fork, tags don't exist so we can't generate the list.
  }

  let helloWorld;
  globby.sync(fileList).forEach(function (file) {
    const demo = filePathToModuleId(
      path.relative("Apps/Sandcastle/gallery", file)
    );

    const demoObject = {
      name: demo,
      isNew: newDemos.includes(file),
    };

    if (fs.existsSync(file.replace(".html", "") + ".jpg")) {
      demoObject.img = demo + ".jpg";
    }

    demoObjects.push(demoObject);

    if (demo === "Hello World") {
      helloWorld = demoObject;
    }
  });

  demoObjects.sort(function (a, b) {
    if (a.name < b.name) {
      return -1;
    } else if (a.name > b.name) {
      return 1;
    }
    return 0;
  });

  const helloWorldIndex = Math.max(demoObjects.indexOf(helloWorld), 0);

  for (let i = 0; i < demoObjects.length; ++i) {
    demoJSONs[i] = JSON.stringify(demoObjects[i], null, 2);
  }

  const contents =
    "\
// This file is automatically rebuilt by the Cesium build process.\n\
const hello_world_index = " +
    helloWorldIndex +
    ";\n\
const VERSION = '" +
    version +
    "';\n\
const gallery_demos = [" +
    demoJSONs.join(", ") +
    "];\n\
const has_new_gallery_demos = " +
    (newDemos.length > 0 ? "true;" : "false;") +
    "\n";

  fs.writeFileSync(output, contents);

  // Compile CSS for Sandcastle
  return streamToPromise(
    gulp
      .src(path.join("Apps", "Sandcastle", "templates", "bucketRaw.css"))
      .pipe(cleanCSS())
      .pipe(gulpRename("bucket.css"))
      .pipe(
        gulpInsert.prepend(
          "/* This file is automatically rebuilt by the Cesium build process. */\n"
        )
      )
      .pipe(gulp.dest(path.join("Apps", "Sandcastle", "templates")))
  );
}

function createJsHintOptions() {
  const primary = JSON.parse(
    fs.readFileSync(path.join("Apps", ".jshintrc"), "utf8")
  );
  const gallery = JSON.parse(
    fs.readFileSync(path.join("Apps", "Sandcastle", ".jshintrc"), "utf8")
  );
  primary.jasmine = false;
  primary.predef = gallery.predef;
  primary.unused = gallery.unused;
  primary.esversion = gallery.esversion;

  const contents =
    "\
// This file is automatically rebuilt by the Cesium build process.\n\
const sandcastleJsHintOptions = " +
    JSON.stringify(primary, null, 4) +
    ";\n";

  fs.writeFileSync(
    path.join("Apps", "Sandcastle", "jsHintOptions.js"),
    contents
  );
}

function buildSandcastle() {
  const appStream = gulp
    .src([
      "Apps/Sandcastle/**",
      "!Apps/Sandcastle/load-cesium-es6.js",
      "!Apps/Sandcastle/standalone.html",
      "!Apps/Sandcastle/images/**",
      "!Apps/Sandcastle/gallery/**.jpg",
    ])
    // Remove dev-only ES6 module loading for unbuilt Cesium
    .pipe(
      gulpReplace(
        '    <script type="module" src="../load-cesium-es6.js"></script>',
        ""
      )
    )
    .pipe(gulpReplace("nomodule", ""))
    // Fix relative paths for new location
    .pipe(gulpReplace("../../../Build", "../../.."))
    .pipe(gulpReplace("../../Source", "../../../Source"))
    .pipe(gulpReplace("../../ThirdParty", "../../../ThirdParty"))
    .pipe(gulpReplace("../../SampleData", "../../../../Apps/SampleData"))
    .pipe(gulpReplace("Build/Documentation", "Documentation"))
    .pipe(gulp.dest("Build/Apps/Sandcastle"));

  const imageStream = gulp
    .src(["Apps/Sandcastle/gallery/**.jpg", "Apps/Sandcastle/images/**"], {
      base: "Apps/Sandcastle",
      buffer: false,
    })
    .pipe(gulp.dest("Build/Apps/Sandcastle"));

  const standaloneStream = gulp
    .src(["Apps/Sandcastle/standalone.html"])
    .pipe(
      gulpReplace(
        '    <script type="module" src="load-cesium-es6.js"></script>',
        ""
      )
    )
    .pipe(gulpReplace("nomodule", ""))
    .pipe(gulpReplace("../../Build", "../.."))
    .pipe(gulp.dest("Build/Apps/Sandcastle"));

  return streamToPromise(mergeStream(appStream, imageStream, standaloneStream));
}

function buildCesiumViewer() {
  const cesiumViewerOutputDirectory = "Build/Apps/CesiumViewer";
  mkdirp.sync(cesiumViewerOutputDirectory);

  let promise = Promise.join(
    rollup
      .rollup({
        input: "Apps/CesiumViewer/CesiumViewer.js",
        treeshake: {
          moduleSideEffects: false,
        },
        plugins: [
          rollupPluginStripPragma({
            pragmas: ["debug"],
          }),
          rollupPluginUglify.uglify(),
        ],
        onwarn: rollupWarning,
      })
      .then(function (bundle) {
        return bundle.write({
          file: "Build/Apps/CesiumViewer/CesiumViewer.js",
          format: "iife",
        });
      })
  );

  promise = promise.then(function () {
    const stream = mergeStream(
      gulp
        .src("Build/Apps/CesiumViewer/CesiumViewer.js")
        .pipe(gulpInsert.prepend(copyrightHeader))
        .pipe(gulpReplace("../../Source", "."))
        .pipe(gulp.dest(cesiumViewerOutputDirectory)),

      gulp
        .src("Apps/CesiumViewer/CesiumViewer.css")
        .pipe(cleanCSS())
        .pipe(gulpReplace("../../Source", "."))
        .pipe(gulp.dest(cesiumViewerOutputDirectory)),

      gulp
        .src("Apps/CesiumViewer/index.html")
        .pipe(gulpReplace('type="module"', ""))
        .pipe(gulp.dest(cesiumViewerOutputDirectory)),

      gulp.src([
        "Apps/CesiumViewer/**",
        "!Apps/CesiumViewer/index.html",
        "!Apps/CesiumViewer/**/*.js",
        "!Apps/CesiumViewer/**/*.css",
      ]),

      gulp.src(
        [
          "Build/Cesium/Assets/**",
          "Build/Cesium/Workers/**",
          "Build/Cesium/ThirdParty/**",
          "Build/Cesium/Widgets/**",
          "!Build/Cesium/Widgets/**/*.css",
        ],
        {
          base: "Build/Cesium",
          nodir: true,
        }
      ),

      gulp.src(["Build/Cesium/Widgets/InfoBox/InfoBoxDescription.css"], {
        base: "Build/Cesium",
      }),

      gulp.src(["web.config"])
    );

    return streamToPromise(stream.pipe(gulp.dest(cesiumViewerOutputDirectory)));
  });

  return promise;
}

function filePathToModuleId(moduleId) {
  return moduleId.substring(0, moduleId.lastIndexOf(".")).replace(/\\/g, "/");
}
