"use strict";

const fs = require("fs-extra");
const path = require("path");
const os = require("os");
const { URL } = require("whatwg-url");
const { camelCase } = require("yargs-parser");
const dedent = require("dedent");
const initPackageJson = require("pify")(require("init-package-json"));
const pacote = require("pacote");
const npa = require("npm-package-arg");
const pReduce = require("p-reduce");
const slash = require("slash");

const { Command } = require("@lerna/command");
const childProcess = require("@lerna/child-process");
const npmConf = require("@lerna/npm-conf");
const { ValidationError } = require("@lerna/validation-error");
const { builtinNpmrc } = require("./lib/builtin-npmrc");
const { catFile } = require("./lib/cat-file");

const LERNA_MODULE_DATA = require.resolve("./lib/lerna-module-data.js");
const DEFAULT_DESCRIPTION = [
  "Now I’m the model of a modern major general",
  "The venerated Virginian veteran whose men are all",
  "Lining up, to put me up on a pedestal",
  "Writin’ letters to relatives",
  "Embellishin’ my elegance and eloquence",
  "But the elephant is in the room",
  "The truth is in ya face when ya hear the British cannons go",
  "BOOM",
].join(" / ");

module.exports = factory;

function factory(argv) {
  return new CreateCommand(argv);
}

class CreateCommand extends Command {
  initialize() {
    const {
      bin,
      description = DEFAULT_DESCRIPTION,
      esModule,
      keywords,
      license,
      loc: pkgLocation,
      name: pkgName,
      yes,
    } = this.options;

    // npm-package-arg handles all the edge-cases with scopes
    const { name, scope } = npa(pkgName);

    // optional scope is _not_ included in the directory name
    this.dirName = scope ? name.split("/").pop() : name;
    this.pkgName = name;
    this.pkgsDir =
      this.project.packageParentDirs.find((pd) => pd.indexOf(pkgLocation) > -1) ||
      this.project.packageParentDirs[0];

    this.camelName = camelCase(this.dirName);

    // when transpiling, src => dist; otherwise everything in lib
    this.outDir = esModule ? "dist" : "lib";
    this.targetDir = path.resolve(this.pkgsDir, this.dirName);

    this.binDir = path.join(this.targetDir, "bin");
    this.binFileName = bin === true ? this.dirName : bin;

    this.libDir = path.join(this.targetDir, esModule ? "src" : "lib");
    this.libFileName = `${this.dirName}.js`;

    this.testDir = path.join(this.targetDir, "__tests__");
    this.testFileName = `${this.dirName}.test.js`;

    this.conf = npmConf({
      description,
      esModule,
      keywords,
      scope,
      yes,
    });

    // consume "builtin" npm config, if it exists (matches npm cli behaviour)
    this.conf.addFile(builtinNpmrc(), "builtin");

    // always set init-main, it's half of the whole point of this module
    this.conf.set("init-main", `${this.outDir}/${this.libFileName}`);

    if (esModule) {
      this.conf.set("init-es-module", `${this.outDir}/${this.dirName}.module.js`);
    }

    // allow default init-version when independent versioning enabled
    if (!this.project.isIndependent()) {
      this.conf.set("init-version", this.project.version);
    }

    // default author metadata with git config
    if (this.conf.get("init-author-name") === "") {
      this.conf.set("init-author-name", this.gitConfig("user.name"));
    }

    if (this.conf.get("init-author-email") === "") {
      this.conf.set("init-author-email", this.gitConfig("user.email"));
    }

    // override npm_config_init_license if --license provided
    if (license) {
      this.conf.set("init-license", license);
    }

    // pass --private into module data without aggravating eslint
    if (this.options.private) {
      this.conf.set("private", true);
    }

    // silence output if logging is silenced
    // istanbul ignore else
    if (this.options.loglevel === "silent") {
      this.conf.set("silent", true);
    }

    // save read-package-json the trouble
    if (this.binFileName) {
      this.conf.set("bin", {
        [this.binFileName]: `bin/${this.binFileName}`,
      });
    }

    // setting _both_ pkg.bin and pkg.directories.bin is an error
    // https://docs.npmjs.com/files/package.json#directoriesbin
    this.conf.set("directories", {
      lib: this.outDir,
      test: "__tests__",
    });

    this.setFiles();
    this.setHomepage();
    this.setPublishConfig();
    this.setRepository();

    return Promise.resolve(this.setDependencies());
  }

  execute() {
    let chain = Promise.resolve();

    chain = chain.then(() => fs.mkdirp(this.libDir));
    chain = chain.then(() => fs.mkdirp(this.testDir));
    chain = chain.then(() => Promise.all([this.writeReadme(), this.writeLibFile(), this.writeTestFile()]));

    if (this.binFileName) {
      chain = chain.then(() => fs.mkdirp(this.binDir));
      chain = chain.then(() => Promise.all([this.writeBinFile(), this.writeCliFile(), this.writeCliTest()]));
    }

    chain = chain.then(() => initPackageJson(this.targetDir, LERNA_MODULE_DATA, this.conf));

    return chain.then((data) => {
      if (this.options.esModule) {
        this.logger.notice(
          "✔",
          dedent`
              Ensure '${path.relative(".", this.pkgsDir)}/*/${this.outDir}' has been added to ./.gitignore
              Ensure rollup or babel build scripts are in the root
            `
        );
      }

      this.logger.success(
        "create",
        `New package ${data.name} created at ./${path.relative(".", this.targetDir)}`
      );
    });
  }

  gitConfig(prop) {
    return childProcess.execSync("git", ["config", "--get", prop], this.execOpts);
  }

  collectExternalVersions() {
    // collect all current externalDependencies
    const extVersions = new Map();

    for (const { externalDependencies } of this.packageGraph.values()) {
      for (const [name, resolved] of externalDependencies) {
        extVersions.set(name, resolved.fetchSpec);
      }
    }

    return extVersions;
  }

  hasLocalRelativeFileSpec() {
    // if any local dependencies are specified as `file:../dir`,
    // all new local dependencies should be created thusly
    for (const { localDependencies } of this.packageGraph.values()) {
      for (const spec of localDependencies.values()) {
        if (spec.type === "directory") {
          return true;
        }
      }
    }
  }

  resolveRelative(depNode) {
    // a relative file: specifier is _always_ posix
    const relPath = path.relative(this.targetDir, depNode.location);
    const spec = npa.resolve(depNode.name, relPath, this.targetDir);

    return slash(spec.saveSpec);
  }

  setDependencies() {
    const inputs = new Set((this.options.dependencies || []).sort());

    // add yargs if a bin is required
    if (this.options.bin) {
      inputs.add("yargs");
    }

    if (!inputs.size) {
      return;
    }

    const exts = this.collectExternalVersions();
    const localRelative = this.hasLocalRelativeFileSpec();
    const savePrefix = this.conf.get("save-exact") ? "" : this.conf.get("save-prefix");
    const pacoteOpts = this.conf.snapshot;

    const decideVersion = (spec) => {
      if (this.packageGraph.has(spec.name)) {
        // sibling dependency
        const depNode = this.packageGraph.get(spec.name);

        if (localRelative) {
          // a local `file:../foo` specifier
          return this.resolveRelative(depNode);
        }

        // yarn workspace or lerna packages config
        return `${savePrefix}${depNode.version}`;
      }

      if (spec.type === "tag" && spec.fetchSpec === "latest") {
        // resolve the latest version
        if (exts.has(spec.name)) {
          // from local external dependency
          return exts.get(spec.name);
        }

        // from registry
        return pacote.manifest(spec, pacoteOpts).then((pkg) => `${savePrefix}${pkg.version}`);
      }

      if (spec.type === "git") {
        throw new ValidationError("EGIT", "Do not use git dependencies");
      }

      // TODO: resolve this if it's weird? (foo@1, bar@^2, etc)
      return spec.rawSpec;
    };

    let chain = Promise.resolve();

    chain = chain.then(() =>
      pReduce(
        inputs,
        (obj, input) => {
          const spec = npa(input);

          return Promise.resolve(spec)
            .then(decideVersion)
            .then((version) => {
              obj[spec.name] = version;

              return obj;
            });
        },
        {}
      )
    );

    chain = chain.then((dependencies) => {
      this.conf.set("dependencies", dependencies);
    });

    return chain;
  }

  setFiles() {
    // no need to glob for files we already know
    const files = [this.outDir];

    if (this.options.bin) {
      files.unshift("bin");
    }

    this.conf.set("files", files);
  }

  setHomepage() {
    // allow --homepage override, but otherwise use root pkg.homepage, if it exists
    let { homepage = this.project.manifest.get("homepage") } = this.options;

    if (!homepage) {
      // normalize-package-data will backfill from hosted-git-info, if possible
      return;
    }

    // allow schemeless URLs (but don't blow up in URL constructor)
    if (homepage.indexOf("http") !== 0) {
      homepage = `http://${homepage}`;
    }

    const hurl = new URL(homepage);
    const relativeTarget = path.relative(this.project.rootPath, this.targetDir);

    if (hurl.hostname.match("github")) {
      hurl.pathname = path.posix.join(hurl.pathname, "tree/main", relativeTarget);
      // TODO: get actual upstream HEAD branch name
      // current remote: git rev-parse --abbrev-ref --symbolic-full-name @{u}
      // upstream HEAD: git symbolic-ref --short refs/remotes/origin/HEAD
      hurl.hash = "readme";
    } else if (!this.options.homepage) {
      // don't mutate an explicit --homepage value
      hurl.pathname = path.posix.join(hurl.pathname, relativeTarget);
    }

    this.conf.set("homepage", hurl.href);
  }

  setPublishConfig() {
    const scope = this.conf.get("scope");
    const registry = this.options.registry || this.conf.get(`${scope}:registry`) || this.conf.get("registry");
    const isPublicRegistry = registry === this.conf.root.registry;
    const publishConfig = {};

    if (scope && isPublicRegistry) {
      publishConfig.access = this.options.access || "public";
    }

    if (registry && !isPublicRegistry) {
      publishConfig.registry = registry;
    }

    if (this.options.tag) {
      publishConfig.tag = this.options.tag;
    }

    if (Object.keys(publishConfig).length) {
      this.conf.set("publishConfig", publishConfig);
    }
  }

  setRepository() {
    try {
      const url = childProcess.execSync("git", ["remote", "get-url", "origin"], this.execOpts);

      this.conf.set("repository", url);
    } catch (err) {
      this.logger.warn("ENOREMOTE", "No git remote found, skipping repository property");
    }
  }

  writeReadme() {
    const readmeContent = dedent`
      # \`${this.pkgName}\`

      > ${this.options.description || "TODO: description"}

      ## Usage

      \`\`\`
      ${
        // eslint-disable-next-line no-nested-ternary
        this.options.bin
          ? dedent`
            npm -g i ${this.pkgName}

            ${this.binFileName} --help
          `
          : this.options.esModule
          ? `import ${this.camelName} from '${this.pkgName}';`
          : `const ${this.camelName} = require('${this.pkgName}');`
      }

      // TODO: DEMONSTRATE API
      \`\`\`
    `;

    return catFile(this.targetDir, "README.md", readmeContent);
  }

  writeLibFile() {
    const libContent = this.options.esModule
      ? dedent`
        export default function ${this.camelName}() {
            // TODO
        }
      `
      : dedent`
        'use strict';

        module.exports = ${this.camelName};

        function ${this.camelName}() {
            // TODO
        }
    `;

    return catFile(this.libDir, this.libFileName, libContent);
  }

  writeTestFile() {
    const testContent = this.options.esModule
      ? dedent`
        import ${this.camelName} from '../src/${this.dirName}';

        describe('${this.pkgName}', () => {
            it('needs tests');
        });
      `
      : dedent`
        'use strict';

        const ${this.camelName} = require('..');

        describe('${this.pkgName}', () => {
            it('needs tests');
        });
      `;

    return catFile(this.testDir, this.testFileName, testContent);
  }

  writeCliFile() {
    const cliFileName = "cli.js";
    const cliContent = [
      this.options.esModule
        ? dedent`
          import factory from 'yargs/yargs';
          import ${this.camelName} from './${this.dirName}';

          export default cli;
        `
        : dedent`
          'use strict';

          const factory = require('yargs/yargs');
          const ${this.camelName} = require('./${this.dirName}');

          module.exports = cli;
        `,
      "", // blank line
      dedent`
        function cli(cwd) {
          const parser = factory(null, cwd);

          parser.alias('h', 'help');
          parser.alias('v', 'version');

          parser.usage(
            "$0",
            "TODO: description",
            yargs => {
              yargs.options({
                // TODO: options
              });
            },
            argv => ${this.camelName}(argv)
          );

          return parser;
        }
      `,
    ].join(os.EOL);

    return catFile(this.libDir, cliFileName, cliContent);
  }

  writeCliTest() {
    const cliTestFileName = "cli.test.js";
    const cliTestContent = [
      this.options.esModule
        ? dedent`
          import cli from '../src/cli';
        `
        : dedent`
          'use strict';

          const cli = require('../lib/cli');
        `,
      "", // blank line
      dedent`
        describe('${this.pkgName} cli', () => {
          // const argv = cli(cwd).parse(['args']);
          it('needs tests');
        });
      `,
    ].join(os.EOL);

    return catFile(this.testDir, cliTestFileName, cliTestContent);
  }

  writeBinFile() {
    const binContent = dedent`
      #!/usr/bin/env node

      'use strict';

      // eslint-disable-next-line no-unused-expressions
      require('../${this.outDir}/cli')${
      this.options.esModule ? ".default" : ""
    }().parse(process.argv.slice(2));`;

    return catFile(this.binDir, this.binFileName, binContent, { mode: 0o755 });
  }
}

module.exports.CreateCommand = CreateCommand;
