#!/usr/bin/env node
/**
 * Copyright (c) 2015-present, Facebook, Inc.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

/**
 * Initially taken from the Create React App project.
 */

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//   /!\ DO NOT MODIFY THIS FILE /!\
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// create-static-site is installed globally on people's computers. This means
// that it is extremely difficult to have them upgrade the version and
// because there's only one global version installed, it is very prone to
// breaking changes.
//
// The only job of create-static-site is to init the repository and then
// forward all the commands to the local version of create-static-site.
//
// If you need to add a new command, please add it to the scripts/ folder.
//
// The only reason to modify this file is to add more warnings and
// troubleshooting information for the `create-static-site` command.
//
// Do not make breaking changes! We absolutely don't want to have to
// tell people to update their global version of create-static-site.
//
// Also be careful with new language features.
// This file must work on Node 6+.
//
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//   /!\ DO NOT MODIFY THIS FILE /!\
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

"use strict"

const validateProjectName = require("validate-npm-package-name")
const chalk = require("chalk")
const commander = require("commander")
const fs = require("fs-extra")
const path = require("path")
const execSync = require("child_process").execSync
const spawn = require("cross-spawn")
const semver = require("semver")
const dns = require("dns")
const tmp = require("tmp")
const unpack = require("tar-pack").unpack
const url = require("url")
const hyperquest = require("hyperquest")

const packageJson = require("./package.json")

let projectName

const program = new commander.Command(packageJson.name)
  .version(packageJson.version)
  .arguments("<project-directory>")
  .usage(`${chalk.green("<project-directory>")} [options]`)
  .action(name => {
    projectName = name
  })
  .option("--template <template>", "hugo or jekyll")
  .option("--verbose", "print additional logs")
  .option(
    "--scripts-version <alternative-package>",
    "use a non-standard version of static-scripts"
  )
  .option("--use-npm")
  .allowUnknownOption()
  .on("--help", () => {
    console.log(`    Only ${chalk.green("<project-directory>")} is required.`)
    console.log()
    console.log(
      `    A custom ${chalk.cyan("--scripts-version")} can be one of:`
    )
    console.log(`      - a specific npm version: ${chalk.green("0.8.2")}`)
    console.log(
      `      - a custom fork published on npm: ${chalk.green(
        "my-static-scripts"
      )}`
    )
    console.log(
      `      - a .tgz archive: ${chalk.green(
        "https://mysite.com/my-static-scripts-0.8.2.tgz"
      )}`
    )
    console.log(
      `    It is not needed unless you specifically want to use a fork.`
    )
    console.log()
    console.log(
      `    If you have any problems, do not hesitate to file an issue:`
    )
    console.log(
      `      ${chalk.cyan(
        "https://github.com/forestryio/create-static-site/issues/new"
      )}`
    )
    console.log()
  })
  .parse(process.argv)

if (typeof projectName === "undefined") {
  console.error("Please specify the project directory:")
  console.log(
    `  ${chalk.cyan(program.name())} ${chalk.green(
      "<project-directory> --template=<template>"
    )}`
  )
  console.log()
  console.log("For example:")
  console.log(
    `  ${chalk.cyan(program.name())} ${chalk.green(
      "my-hugo-site --template=hugo"
    )}`
  )
  console.log()
  console.log(
    `Run ${chalk.cyan(`${program.name()} --help`)} to see all options.`
  )
  process.exit(1)
}
if (typeof program.template === "undefined") {
  console.error("Please specify the project template:")
  console.log(
    `  ${chalk.cyan(program.name())} ${chalk.green(
      "<project-directory> --template=<template>"
    )}`
  )
  console.log()
  console.log("For example:")
  console.log(
    `  ${chalk.cyan(program.name())} ${chalk.green(
      "my-hugo-site --template=hugo"
    )}`
  )
  console.log()
  console.log(
    `Run ${chalk.cyan(`${program.name()} --help`)} to see all options.`
  )
  process.exit(1)
}

function printValidationResults(results) {
  if (typeof results !== "undefined") {
    results.forEach(error => {
      console.error(chalk.red(`  *  ${error}`))
    })
  }
}

createApp(
  projectName,
  program.verbose,
  program.scriptsVersion,
  program.useNpm,
  program.template
)

function createApp(name, verbose, version, useNpm, template) {
  const root = path.resolve(name)
  const appName = path.basename(root)

  checkAppName(appName)
  fs.ensureDirSync(name)
  if (!isSafeToCreateProjectIn(root, name)) {
    process.exit(1)
  }

  console.log(`Creating a new Static Site in ${chalk.green(root)}.`)
  console.log()

  const packageJson = {
    name: appName,
    version: "0.1.0",
  }
  fs.writeFileSync(
    path.join(root, "package.json"),
    JSON.stringify(packageJson, null, 2)
  )

  const useYarn = useNpm ? false : shouldUseYarn()
  const originalDirectory = process.cwd()
  process.chdir(root)
  if (!useYarn && !checkThatNpmCanReadCwd()) {
    process.exit(1)
  }

  if (!semver.satisfies(process.version, ">=6.0.0")) {
    console.log(
      chalk.yellow(
        `You are using Node ${
          process.version
        } so the project will be bootstrapped with an old unsupported version of tools.\n\n` +
          `Please update to Node 6 or higher for a better, fully supported experience.\n`
      )
    )
    process.exit(1)
  }

  if (!useYarn) {
    const npmInfo = checkNpmVersion()
    if (!npmInfo.hasMinNpm) {
      if (npmInfo.npmVersion) {
        console.log(
          chalk.yellow(
            `You are using npm ${
              npmInfo.npmVersion
            } so the project will be boostrapped with an old unsupported version of tools.\n\n` +
              `Please update to npm 3 or higher for a better, fully supported experience.\n`
          )
        )
      }
      // Fall back to latest supported static-scripts for npm 3
      process.exit(1)
    }
  }
  run(root, appName, version, verbose, originalDirectory, template, useYarn)
}

function shouldUseYarn() {
  try {
    execSync("yarnpkg --version", { stdio: "ignore" })
    return true
  } catch (e) {
    return false
  }
}

function install(root, useYarn, dependencies, verbose, isOnline) {
  return new Promise((resolve, reject) => {
    let command
    let args
    if (useYarn) {
      command = "yarnpkg"
      args = ["add", "--dev", "--exact"]
      if (!isOnline) {
        args.push("--offline")
      }
      ;[].push.apply(args, dependencies)

      // Explicitly set cwd() to work around issues like
      // https://github.com/forestryio/create-static-site/issues/3326.
      // Unfortunately we can only do this for Yarn because npm support for
      // equivalent --prefix flag doesn't help with this issue.
      // This is why for npm, we run checkThatNpmCanReadCwd() early instead.
      args.push("--cwd")
      args.push(root)

      if (!isOnline) {
        console.log(chalk.yellow("You appear to be offline."))
        console.log(chalk.yellow("Falling back to the local Yarn cache."))
        console.log()
      }
    } else {
      command = "npm"
      args = [
        "install",
        `--save-dev`,
        "--save-exact",
        "--loglevel",
        "error",
      ].concat(dependencies)
    }

    if (verbose) {
      args.push("--verbose")
    }

    const child = spawn(command, args, { stdio: "inherit" })
    child.on("close", code => {
      if (code !== 0) {
        reject({
          command: `${command} ${args.join(" ")}`,
        })
        return
      }
      resolve()
    })
  })
}

function run(
  root,
  appName,
  version,
  verbose,
  originalDirectory,
  template,
  useYarn
) {
  const packageToInstall = getInstallPackage(version)

  console.log("Installing packages. This might take a couple of minutes.")
  getPackageName(packageToInstall)
    .then(packageName =>
      checkIfOnline(useYarn).then(isOnline => ({
        isOnline: isOnline,
        packageName: packageName,
      }))
    )
    .then(info => {
      const isOnline = info.isOnline
      const packageName = info.packageName
      console.log(`Installing ${chalk.cyan(packageName)}...`)
      console.log()
      const devDependencies = [packageToInstall]
      return install(
        root,
        useYarn,
        devDependencies,
        verbose,
        isOnline,
        true
      ).then(() => packageName)
    })
    .then(packageName => {
      checkNodeVersion(packageName)
      setCaretRangeForRuntimeDeps(packageName)

      const scriptsPath = path.resolve(
        process.cwd(),
        "node_modules",
        packageName,
        "scripts",
        "init.js"
      )
      const init = require(scriptsPath)
      init(root, appName, verbose, originalDirectory, template)
    })
    .catch(reason => {
      console.log()
      console.log("Aborting installation.")
      if (reason.command) {
        console.log(`  ${chalk.cyan(reason.command)} has failed.`)
      } else {
        console.log(chalk.red("Unexpected error. Please report it as a bug:"))
        console.log(reason)
      }
      console.log()

      // On 'exit' we will delete these files from target directory.
      const knownGeneratedFiles = [
        "package.json",
        "npm-debug.log",
        "yarn-error.log",
        "yarn-debug.log",
        "node_modules",
      ]
      const currentFiles = fs.readdirSync(path.join(root))
      currentFiles.forEach(file => {
        knownGeneratedFiles.forEach(fileToMatch => {
          // This will catch `(npm-debug|yarn-error|yarn-debug).log*` files
          // and the rest of knownGeneratedFiles.
          if (
            (fileToMatch.match(/.log/g) && file.indexOf(fileToMatch) === 0) ||
            file === fileToMatch
          ) {
            console.log(`Deleting generated file... ${chalk.cyan(file)}`)
            fs.removeSync(path.join(root, file))
          }
        })
      })
      const remainingFiles = fs.readdirSync(path.join(root))
      if (!remainingFiles.length) {
        // Delete target folder if empty
        console.log(
          `Deleting ${chalk.cyan(`${appName} /`)} from ${chalk.cyan(
            path.resolve(root, "..")
          )}`
        )
        process.chdir(path.resolve(root, ".."))
        fs.removeSync(path.join(root))
      }
      console.log("Done.")
      process.exit(1)
    })
}

function getInstallPackage(version) {
  let packageToInstall = "static-scripts"
  const validSemver = semver.valid(version)
  if (validSemver) {
    packageToInstall += `@${validSemver}`
  } else if (version) {
    // for tar.gz or alternative paths
    packageToInstall = version
  }
  return packageToInstall
}

function getTemporaryDirectory() {
  return new Promise((resolve, reject) => {
    // Unsafe cleanup lets us recursively delete the directory if it contains
    // contents; by default it only allows removal if it's empty
    tmp.dir({ unsafeCleanup: true }, (err, tmpdir, callback) => {
      if (err) {
        reject(err)
      } else {
        resolve({
          tmpdir: tmpdir,
          cleanup: () => {
            try {
              callback()
            } catch (ignored) {
              // Callback might throw and fail, since it's a temp directory the
              // OS will clean it up eventually...
            }
          },
        })
      }
    })
  })
}

function extractStream(stream, dest) {
  return new Promise((resolve, reject) => {
    stream.pipe(
      unpack(dest, err => {
        if (err) {
          reject(err)
        } else {
          resolve(dest)
        }
      })
    )
  })
}

// Extract package name from tarball url or path.
function getPackageName(installPackage) {
  if (installPackage.indexOf(".tgz") > -1) {
    return getTemporaryDirectory()
      .then(obj => {
        let stream
        if (/^http/.test(installPackage)) {
          stream = hyperquest(installPackage)
        } else {
          stream = fs.createReadStream(installPackage)
        }
        return extractStream(stream, obj.tmpdir).then(() => obj)
      })
      .then(obj => {
        const packageName = require(path.join(obj.tmpdir, "package.json")).name
        obj.cleanup()
        return packageName
      })
      .catch(err => {
        // The package name could be with or without semver version, e.g. static-scripts-0.2.0-alpha.1.tgz
        // However, this function returns package name only without semver version.
        console.log(
          `Could not extract the package name from the archive: ${err.message}`
        )
        const assumedProjectName = installPackage.match(
          /^.+\/(.+?)(?:-\d+.+)?\.tgz$/
        )[1]
        console.log(
          `Based on the filename, assuming it is "${chalk.cyan(
            assumedProjectName
          )}"`
        )
        return Promise.resolve(assumedProjectName)
      })
  } else if (installPackage.indexOf("git+") === 0) {
    // Pull package name out of git urls e.g:
    // git+https://github.com/mycompany/static-scripts.git
    // git+ssh://github.com/mycompany/static-scripts.git#v1.2.3
    return Promise.resolve(installPackage.match(/([^/]+)\.git(#.*)?$/)[1])
  } else if (installPackage.match(/.+@/)) {
    // Do not match @scope/ when stripping off @version or @tag
    return Promise.resolve(
      installPackage.charAt(0) + installPackage.substr(1).split("@")[0]
    )
  }
  return Promise.resolve(installPackage)
}

function checkNpmVersion() {
  let hasMinNpm = false
  let npmVersion = null
  try {
    npmVersion = execSync("npm --version")
      .toString()
      .trim()
    hasMinNpm = semver.gte(npmVersion, "3.0.0")
  } catch (err) {
    // ignore
  }
  return {
    hasMinNpm: hasMinNpm,
    npmVersion: npmVersion,
  }
}

function checkNodeVersion(packageName) {
  const packageJsonPath = path.resolve(
    process.cwd(),
    "node_modules",
    packageName,
    "package.json"
  )
  const packageJson = require(packageJsonPath)
  if (!packageJson.engines || !packageJson.engines.node) {
    return
  }

  if (!semver.satisfies(process.version, packageJson.engines.node)) {
    console.error(
      chalk.red(
        "You are running Node %s.\n" +
          "Create Static Sites requires Node %s or higher. \n" +
          "Please update your version of Node."
      ),
      process.version,
      packageJson.engines.node
    )
    process.exit(1)
  }
}

function checkAppName(appName) {
  const validationResult = validateProjectName(appName)
  if (!validationResult.validForNewPackages) {
    console.error(
      `Could not create a project called ${chalk.red(
        `"${appName}"`
      )} because of npm naming restrictions:`
    )
    printValidationResults(validationResult.errors)
    printValidationResults(validationResult.warnings)
    process.exit(1)
  }

  // TODO: there should be a single place that holds the dependencies
  const dependencies = ["static-scripts"].sort()
  if (dependencies.indexOf(appName) >= 0) {
    console.error(
      chalk.red(
        `We cannot create a project called ${chalk.green(
          appName
        )} because a dependency with the same name exists.\n` +
          `Due to the way npm works, the following names are not allowed:\n\n`
      ) +
        chalk.cyan(dependencies.map(depName => `  ${depName}`).join("\n")) +
        chalk.red("\n\nPlease choose a different project name.")
    )
    process.exit(1)
  }
}

function makeCaretRange(dependencies, name) {
  const version = dependencies[name]

  if (typeof version === "undefined") {
    console.error(chalk.red(`Missing ${name} dependency in package.json`))
    process.exit(1)
  }

  let patchedVersion = `^${version}`

  if (!semver.validRange(patchedVersion)) {
    console.error(
      `Unable to patch ${name} dependency version because version ${chalk.red(
        version
      )} will become invalid ${chalk.red(patchedVersion)}`
    )
    patchedVersion = version
  }

  dependencies[name] = patchedVersion
}

function setCaretRangeForRuntimeDeps(packageName) {
  const packagePath = path.join(process.cwd(), "package.json")
  const packageJson = require(packagePath)

  if (typeof packageJson.devDependencies === "undefined") {
    console.error(chalk.red("Missing devDependencies in package.json"))
    process.exit(1)
  }

  const packageVersion = packageJson.devDependencies[packageName]
  if (typeof packageVersion === "undefined") {
    console.error(chalk.red(`Unable to find ${packageName} in package.json`))
    process.exit(1)
  }

  // makeCaretRange(packageJson.devDependencies, "typescript")

  fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2))
}

// If project only contains files generated by GH, it’s safe.
// We also special case IJ-based products .idea because it integrates with CRA:
// https://github.com/forestryio/create-static-site/pull/368#issuecomment-243446094
function isSafeToCreateProjectIn(root, name) {
  const validFiles = [
    ".DS_Store",
    "Thumbs.db",
    ".git",
    ".gitignore",
    ".idea",
    "README.md",
    "LICENSE",
    "web.iml",
    ".hg",
    ".hgignore",
    ".hgcheck",
  ]
  console.log()

  const conflicts = fs
    .readdirSync(root)
    .filter(file => !validFiles.includes(file))
  if (conflicts.length < 1) {
    return true
  }

  console.log(
    `The directory ${chalk.green(name)} contains files that could conflict:`
  )
  console.log()
  for (const file of conflicts) {
    console.log(`  ${file}`)
  }
  console.log()
  console.log(
    "Either try using a new directory name, or remove the files listed above."
  )

  return false
}

function checkThatNpmCanReadCwd() {
  const cwd = process.cwd()
  let childOutput = null
  try {
    // Note: intentionally using spawn over exec since
    // the problem doesn't reproduce otherwise.
    // `npm config list` is the only reliable way I could find
    // to reproduce the wrong path. Just printing process.cwd()
    // in a Node process was not enough.
    childOutput = spawn.sync("npm", ["config", "list"]).output.join("")
  } catch (err) {
    // Something went wrong spawning node.
    // Not great, but it means we can't do this check.
    // We might fail later on, but let's continue.
    return true
  }
  if (typeof childOutput !== "string") {
    return true
  }
  const lines = childOutput.split("\n")
  // `npm config list` output includes the following line:
  // "; cwd = C:\path\to\current\dir" (unquoted)
  // I couldn't find an easier way to get it.
  const prefix = "; cwd = "
  const line = lines.find(line => line.indexOf(prefix) === 0)
  if (typeof line !== "string") {
    // Fail gracefully. They could remove it.
    return true
  }
  const npmCWD = line.substring(prefix.length)
  if (npmCWD === cwd) {
    return true
  }
  console.error(
    chalk.red(
      `Could not start an npm process in the right directory.\n\n` +
        `The current directory is: ${chalk.bold(cwd)}\n` +
        `However, a newly started npm process runs in: ${chalk.bold(
          npmCWD
        )}\n\n` +
        `This is probably caused by a misconfigured system terminal shell.`
    )
  )
  if (process.platform === "win32") {
    console.error(
      chalk.red(`On Windows, this can usually be fixed by running:\n\n`) +
        `  ${chalk.cyan(
          "reg"
        )} delete "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n` +
        `  ${chalk.cyan(
          "reg"
        )} delete "HKLM\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n\n` +
        chalk.red(`Try to run the above two lines in the terminal.\n`) +
        chalk.red(
          `To learn more about this problem, read: https://blogs.msdn.microsoft.com/oldnewthing/20071121-00/?p=24433/`
        )
    )
  }
  return false
}

function checkIfOnline(useYarn) {
  if (!useYarn) {
    // Don't ping the Yarn registry.
    // We'll just assume the best case.
    return Promise.resolve(true)
  }

  return new Promise(resolve => {
    dns.lookup("registry.yarnpkg.com", err => {
      if (err != null && process.env.https_proxy) {
        // If a proxy is defined, we likely can't resolve external hostnames.
        // Try to resolve the proxy name as an indication of a connection.
        dns.lookup(url.parse(process.env.https_proxy).hostname, proxyErr => {
          resolve(proxyErr == null)
        })
      } else {
        resolve(err == null)
      }
    })
  })
}
