const ejs = require('ejs');
// const debug = require('debug');
const GeneratorAPI = require('./GeneratorAPI');
const PackageManager = require('./util/ProjectPackageManager');
const sortObject = require('./util/sortObject');
const writeFileTree = require('./util/writeFileTree');

// TODO:简单实现？
// const inferRootOptions = require('./util/inferRootOptions');
const normalizeFilePaths = require('./util/normalizeFilePaths');
// const { runTransformation } = require('vue-codemod');
// const {
//   semver,

//   isPlugin,
//   toShortPluginId,
//   matchesPluginId,

//   loadModule,

//   sortPlugins
// } = require('@vue/cli-shared-utils');

const semver = require('semver');
const {
  isPlugin,
  toShortPluginId,
  matchesPluginId
} = require('./shared-utils/pluginResolution');
const { sortPlugins } = require('./shared-utils/pluginOrder');

// const ConfigTransform = require('./ConfigTransform');

// const logger = require('@vue/cli-shared-utils/lib/logger');
const logger = require('./shared-utils/logger');
const logTypes = {
  log: logger.log,
  info: logger.info,
  done: logger.done,
  warn: logger.warn,
  error: logger.error
};

const defaultConfigTransforms = {
  // babel: new ConfigTransform({
  //   file: {
  //     js: ['babel.config.js']
  //   }
  // }),
  // postcss: new ConfigTransform({
  //   file: {
  //     js: ['postcss.config.js'],
  //     json: ['.postcssrc.json', '.postcssrc'],
  //     yaml: ['.postcssrc.yaml', '.postcssrc.yml']
  //   }
  // }),
  // eslintConfig: new ConfigTransform({
  //   file: {
  //     js: ['.eslintrc.js'],
  //     json: ['.eslintrc', '.eslintrc.json'],
  //     yaml: ['.eslintrc.yaml', '.eslintrc.yml']
  //   }
  // }),
  // jest: new ConfigTransform({
  //   file: {
  //     js: ['jest.config.js']
  //   }
  // }),
  // browserslist: new ConfigTransform({
  //   file: {
  //     lines: ['.browserslistrc']
  //   }
  // }),
  // 'lint-staged': new ConfigTransform({
  //   file: {
  //     js: ['lint-staged.config.js'],
  //     json: ['.lintstagedrc', '.lintstagedrc.json'],
  //     yaml: ['.lintstagedrc.yaml', '.lintstagedrc.yml']
  //   }
  // })
};

const reservedConfigTransforms = {
  // vue: new ConfigTransform({
  //   file: {
  //     js: ['vue.config.js']
  //   }
  // })
};

const ensureEOL = str => {
  if (str.charAt(str.length - 1) !== '\n') {
    return str + '\n';
  }
  return str;
};

/**
 * Collect created/modified files into set
 * @param {Record<string,string|Buffer>} files
 * @param {Set<string>} set
 */
const watchFiles = (files, set) => {
  return new Proxy(files, {
    set(target, key, value, receiver) {
      set.add(key);
      return Reflect.set(target, key, value, receiver);
    },
    deleteProperty(target, key) {
      set.delete(key);
      return Reflect.deleteProperty(target, key);
    }
  });
};

module.exports = class Generator {
  constructor(
    context,
    {
      pkg = {},
      plugins = [],
      afterInvokeCbs = [],
      afterAnyInvokeCbs = [],
      files = {},
      invoking = false
    } = {}
  ) {
    this.context = context;
    this.plugins = sortPlugins(plugins);
    this.originalPkg = pkg;
    this.pkg = Object.assign({}, pkg);
    this.pm = new PackageManager({ context });
    this.imports = {};
    this.rootOptions = {};
    this.afterInvokeCbs = afterInvokeCbs;
    this.afterAnyInvokeCbs = afterAnyInvokeCbs;
    this.configTransforms = {};
    this.defaultConfigTransforms = defaultConfigTransforms;
    this.reservedConfigTransforms = reservedConfigTransforms;
    this.invoking = invoking;
    // for conflict resolution
    this.depSources = {};

    // virtual file tree
    this.files = Object.keys(files).length
      ? // when execute `vue add/invoke`, only created/modified files are written to disk
        watchFiles(files, (this.filesModifyRecord = new Set()))
      : // all files need to be written to disk
        files;
    this.fileMiddlewares = [];
    this.postProcessFilesCbs = [];
    // exit messages
    this.exitLogs = [];

    // load all the other plugins
    // this.allPlugins = this.resolveAllPlugins();
    // TODO:可以省略？
    this.allPlugins = [];

    const cliService = plugins.find(p => p.id === '@vue/cli-service');
    // const rootOptions = cliService ? cliService.options : inferRootOptions(pkg);
    const rootOptions = cliService ? cliService.options : {};

    this.rootOptions = rootOptions;
  }

  async initPlugins() {
    const { rootOptions, invoking } = this;
    const pluginIds = this.plugins.map(p => p.id);

    // avoid modifying the passed afterInvokes, because we want to ignore them from other plugins
    const passedAfterInvokeCbs = this.afterInvokeCbs;
    this.afterInvokeCbs = [];
    // apply hooks from all plugins to collect 'afterAnyHooks'
    for (const plugin of this.allPlugins) {
      const { id, apply } = plugin;
      const api = new GeneratorAPI(id, this, {}, rootOptions);

      if (apply.hooks) {
        await apply.hooks(api, {}, rootOptions, pluginIds);
      }
    }

    // We are doing save/load to make the hook order deterministic
    // save "any" hooks
    const afterAnyInvokeCbsFromPlugins = this.afterAnyInvokeCbs;

    // reset hooks
    this.afterInvokeCbs = passedAfterInvokeCbs;
    this.afterAnyInvokeCbs = [];
    this.postProcessFilesCbs = [];

    // apply generators from plugins

    for (const plugin of this.plugins) {
      const { id, apply, options } = plugin;
      const api = new GeneratorAPI(id, this, options, rootOptions);
      await apply(api, options, rootOptions, invoking);

      // if (apply.hooks) {
      //   // while we execute the entire `hooks` function,
      //   // only the `afterInvoke` hook is respected
      //   // because `afterAnyHooks` is already determined by the `allPlugins` loop above
      //   await apply.hooks(api, options, rootOptions, pluginIds);
      // }
    }
    // restore "any" hooks
    this.afterAnyInvokeCbs = afterAnyInvokeCbsFromPlugins;
  }

  async generate({ extractConfigFiles = false, checkExisting = false } = {}) {
    await this.initPlugins();
    // save the file system before applying plugin for comparison
    const initialFiles = Object.assign({}, this.files);
    // // extract configs from package.json into dedicated files.
    // this.extractConfigFiles(extractConfigFiles, checkExisting);
    // wait for file resolve
    await this.resolveFiles();
    // set package.json
    this.sortPkg();
    this.files['package.json'] = JSON.stringify(this.pkg, null, 2) + '\n';
    // write/update file tree to disk
    await writeFileTree(
      this.context,
      this.files,
      initialFiles,
      this.filesModifyRecord
    );
  }

  extractConfigFiles(extractAll, checkExisting) {
    const configTransforms = Object.assign(
      {},
      defaultConfigTransforms,
      this.configTransforms,
      reservedConfigTransforms
    );
    const extract = key => {
      if (
        configTransforms[key] &&
        this.pkg[key] &&
        // do not extract if the field exists in original package.json
        !this.originalPkg[key]
      ) {
        const value = this.pkg[key];
        const configTransform = configTransforms[key];
        const res = configTransform.transform(
          value,
          checkExisting,
          this.files,
          this.context
        );
        const { content, filename } = res;
        this.files[filename] = ensureEOL(content);
        delete this.pkg[key];
      }
    };
    if (extractAll) {
      for (const key in this.pkg) {
        extract(key);
      }
    } else {
      if (!process.env.VUE_CLI_TEST) {
        // by default, always extract vue.config.js
        extract('vue');
      }
      // always extract babel.config.js as this is the only way to apply
      // project-wide configuration even to dependencies.
      // TODO: this can be removed when Babel supports root: true in package.json
      extract('babel');
    }
  }

  sortPkg() {
    // ensure package.json keys has readable order
    this.pkg.dependencies = sortObject(this.pkg.dependencies);
    this.pkg.devDependencies = sortObject(this.pkg.devDependencies);
    this.pkg.scripts = sortObject(this.pkg.scripts, [
      'serve',
      'build',
      'test:unit',
      'test:e2e',
      'lint',
      'deploy'
    ]);
    this.pkg = sortObject(this.pkg, [
      'name',
      'version',
      'private',
      'description',
      'author',
      'scripts',
      'main',
      'module',
      'browser',
      'jsDelivr',
      'unpkg',
      'files',
      'dependencies',
      'devDependencies',
      'peerDependencies',
      'vue',
      'babel',
      'eslintConfig',
      'prettier',
      'postcss',
      'browserslist',
      'jest'
    ]);

    // debug('vue:cli-pkg')(this.pkg);
  }

  resolveAllPlugins() {
    const allPlugins = [];
    Object.keys(this.pkg.dependencies || {})
      .concat(Object.keys(this.pkg.devDependencies || {}))
      .forEach(id => {
        if (!isPlugin(id)) return;
        const pluginGenerator = loadModule(`${id}/generator`, this.context);
        if (!pluginGenerator) return;
        allPlugins.push({ id, apply: pluginGenerator });
      });
    return sortPlugins(allPlugins);
  }

  async resolveFiles() {
    const files = this.files;
    for (const middleware of this.fileMiddlewares) {
      await middleware(files, ejs.render);
    }

    // normalize file paths on windows
    // all paths are converted to use / instead of \
    normalizeFilePaths(files);

    // handle imports and root option injections
    Object.keys(files).forEach(file => {
      let imports = this.imports[file];
      imports = imports instanceof Set ? Array.from(imports) : imports;
      if (imports && imports.length > 0) {
        // files[file] = runTransformation(
        //   { path: file, source: files[file] },
        //   require('./util/codemods/injectImports'),
        //   { imports }
        // );
      }

      let injections = this.rootOptions[file];
      injections =
        injections instanceof Set ? Array.from(injections) : injections;
      if (injections && injections.length > 0) {
        // files[file] = runTransformation(
        //   { path: file, source: files[file] },
        //   require('./util/codemods/injectOptions'),
        //   { injections }
        // );
      }
    });

    for (const postProcess of this.postProcessFilesCbs) {
      await postProcess(files);
    }
    // debug('vue:cli-files')(this.files);
  }

  hasPlugin(id, versionRange) {
    const pluginExists = [
      ...this.plugins.map(p => p.id),
      ...this.allPlugins.map(p => p.id)
    ].some(pid => matchesPluginId(id, pid));

    if (!pluginExists) {
      return false;
    }

    if (!versionRange) {
      return pluginExists;
    }

    return semver.satisfies(this.pm.getInstalledVersion(id), versionRange);
  }

  printExitLogs() {
    if (this.exitLogs.length) {
      this.exitLogs.forEach(({ id, msg, type }) => {
        const shortId = toShortPluginId(id);
        const logFn = logTypes[type];
        if (!logFn) {
          logger.error(`Invalid api.exitLog type '${type}'.`, shortId);
        } else {
          logFn(msg, msg && shortId);
        }
      });
      logger.log();
    }
  }
};
