'use strict';

/*
 * Usage -- see README.md
 *
 * Normal usage:                node src/bin/plugins/checkPlugin.js ep_whatever
 * Auto fix the things it can:  node src/bin/plugins/checkPlugin.js ep_whatever autofix
 * Auto fix and commit:         node src/bin/plugins/checkPlugin.js ep_whatever autocommit
 * Auto fix, commit, push and publish to npm (highly dangerous):
 *                              node src/bin/plugins/checkPlugin.js ep_whatever autopush
 */

const process = require('process');

// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
process.on('unhandledRejection', (err) => { throw err; });

const assert = require('assert').strict;
const fs = require('fs');
const fsp = fs.promises;
const childProcess = require('child_process');
const log4js = require('log4js');
const path = require('path');

const logger = log4js.getLogger('checkPlugin');

(async () => {
  // get plugin name & path from user input
  const pluginName = process.argv[2];

  if (!pluginName) throw new Error('no plugin name specified');
  logger.info(`Checking the plugin: ${pluginName}`);

  const epRootDir = await fsp.realpath(path.join(await fsp.realpath(__dirname), '../../..'));
  logger.info(`Etherpad root directory: ${epRootDir}`);
  process.chdir(epRootDir);
  const pluginPath = await fsp.realpath(`node_modules/${pluginName}`);
  logger.info(`Plugin directory: ${pluginPath}`);
  const epSrcDir = await fsp.realpath(path.join(epRootDir, 'src'));

  const optArgs = process.argv.slice(3);
  const autoPush = optArgs.includes('autopush');
  const autoCommit = autoPush || optArgs.includes('autocommit');
  const autoFix = autoCommit || optArgs.includes('autofix');

  const execSync = (cmd, opts = {}) => (childProcess.execSync(cmd, {
    cwd: `${pluginPath}/`,
    ...opts,
  }) || '').toString().replace(/\n+$/, '');

  const writePackageJson = async (obj) => {
    let s = JSON.stringify(obj, null, 2);
    if (s.length && s.slice(s.length - 1) !== '\n') s += '\n';
    return await fsp.writeFile(`${pluginPath}/package.json`, s);
  };

  const checkEntries = (got, want) => {
    let changed = false;
    for (const [key, val] of Object.entries(want)) {
      try {
        assert.deepEqual(got[key], val);
      } catch (err) {
        logger.warn(`${key} possibly outdated.`);
        logger.warn(err.message);
        if (autoFix) {
          got[key] = val;
          changed = true;
        }
      }
    }
    return changed;
  };

  const updateDeps = async (parsedPackageJson, key, wantDeps) => {
    const {[key]: deps = {}} = parsedPackageJson;
    let changed = false;
    for (const [pkg, verInfo] of Object.entries(wantDeps)) {
      const {ver, overwrite = true} =
          typeof verInfo === 'string' || verInfo == null ? {ver: verInfo} : verInfo;
      if (deps[pkg] === ver || (deps[pkg] == null && ver == null)) continue;
      if (deps[pkg] == null) {
        logger.warn(`Missing dependency in ${key}: '${pkg}': '${ver}'`);
      } else {
        if (!overwrite) continue;
        logger.warn(`Dependency mismatch in ${key}: '${pkg}': '${ver}' (current: ${deps[pkg]})`);
      }
      if (autoFix) {
        if (ver == null) delete deps[pkg];
        else deps[pkg] = ver;
        changed = true;
      }
    }
    if (changed) {
      parsedPackageJson[key] = deps;
      await writePackageJson(parsedPackageJson);
    }
  };

  const prepareRepo = () => {
    const modified = execSync('git diff-files --name-status');
    if (modified !== '') throw new Error(`working directory has modifications:\n${modified}`);
    const untracked = execSync('git ls-files -o --exclude-standard');
    if (untracked !== '') throw new Error(`working directory has untracked files:\n${untracked}`);
    const indexStatus = execSync('git diff-index --cached --name-status HEAD');
    if (indexStatus !== '') throw new Error(`uncommitted staged changes to files:\n${indexStatus}`);
    let br;
    if (autoCommit) {
      br = execSync('git symbolic-ref HEAD');
      if (!br.startsWith('refs/heads/')) throw new Error('detached HEAD');
      br = br.replace(/^refs\/heads\//, '');
      execSync('git rev-parse --verify -q HEAD^0 || ' +
               `{ echo "Error: no commits on ${br}" >&2; exit 1; }`);
      execSync('git config --get user.name');
      execSync('git config --get user.email');
    }
    if (autoPush) {
      if (!['master', 'main'].includes(br)) throw new Error('master/main not checked out');
      execSync('git rev-parse --verify @{u}');
      execSync('git pull --ff-only', {stdio: 'inherit'});
      if (execSync('git rev-list @{u}...') !== '') throw new Error('repo contains unpushed commits');
    }
  };

  const checkFile = async (srcFn, dstFn, overwrite = true) => {
    const outFn = path.join(pluginPath, dstFn);
    const wantContents = await fsp.readFile(srcFn, {encoding: 'utf8'});
    let gotContents = null;
    try {
      gotContents = await fsp.readFile(outFn, {encoding: 'utf8'});
    } catch (err) { /* treat as if the file doesn't exist */ }
    try {
      assert.equal(gotContents, wantContents);
    } catch (err) {
      logger.warn(`File ${dstFn} does not match the default`);
      logger.warn(err.message);
      if (!overwrite && gotContents != null) {
        logger.warn('Leaving existing contents alone.');
        return;
      }
      if (autoFix) {
        await fsp.mkdir(path.dirname(outFn), {recursive: true});
        await fsp.writeFile(outFn, wantContents);
      }
    }
  };

  if (autoPush) {
    logger.warn('Auto push is enabled, I hope you know what you are doing...');
  }

  const files = await fsp.readdir(pluginPath);

  // some files we need to know the actual file name.  Not compulsory but might help in the future.
  const readMeFileName = files.filter((f) => f === 'README' || f === 'README.md')[0];

  if (!files.includes('.git')) throw new Error('No .git folder, aborting');
  prepareRepo();

  const workflows = ['backend-tests.yml', 'frontend-tests.yml', 'npmpublish.yml'];
  await Promise.all(workflows.map(async (fn) => {
    await checkFile(`src/bin/plugins/lib/${fn}`, `.github/workflows/${fn}`);
  }));
  await checkFile('src/bin/plugins/lib/dependabot.yml', '.github/dependabot.yml');

  if (!files.includes('package.json')) {
    logger.warn('no package.json, please create');
  } else {
    const packageJSON =
        await fsp.readFile(`${pluginPath}/package.json`, {encoding: 'utf8', flag: 'r'});
    const parsedPackageJSON = JSON.parse(packageJSON);

    await updateDeps(parsedPackageJSON, 'devDependencies', {
      'eslint': '^8.14.0',
      'eslint-config-etherpad': '^3.0.13',
      // Changing the TypeScript version can break plugin code, so leave it alone if present.
      'typescript': {ver: '^4.6.4', overwrite: false},
      // These were moved to eslint-config-etherpad's dependencies so they can be removed:
      '@typescript-eslint/eslint-plugin': null,
      '@typescript-eslint/parser': null,
      'eslint-import-resolver-typescript': null,
      'eslint-plugin-cypress': null,
      'eslint-plugin-eslint-comments': null,
      'eslint-plugin-import': null,
      'eslint-plugin-mocha': null,
      'eslint-plugin-node': null,
      'eslint-plugin-prefer-arrow': null,
      'eslint-plugin-promise': null,
      'eslint-plugin-you-dont-need-lodash-underscore': null,
    });

    await updateDeps(parsedPackageJSON, 'peerDependencies', {
      // Some plugins require a newer version of Etherpad so don't overwrite if already set.
      'ep_etherpad-lite': {ver: '>=1.8.6', overwrite: false},
    });

    await updateDeps(parsedPackageJSON, 'engines', {
      node: '>=12.17.0',
    });

    if (parsedPackageJSON.eslintConfig != null && autoFix) {
      delete parsedPackageJSON.eslintConfig;
      await writePackageJson(parsedPackageJSON);
    }
    if (files.includes('.eslintrc.js')) {
      const [from, to] = [`${pluginPath}/.eslintrc.js`, `${pluginPath}/.eslintrc.cjs`];
      if (!files.includes('.eslintrc.cjs')) {
        if (autoFix) {
          await fsp.rename(from, to);
        } else {
          logger.warn(`please rename ${from} to ${to}`);
        }
      } else {
        logger.error(`both ${from} and ${to} exist; delete ${from}`);
      }
    } else {
      checkFile('src/bin/plugins/lib/eslintrc.cjs', '.eslintrc.cjs', false);
    }

    if (checkEntries(parsedPackageJSON, {
      funding: {
        type: 'individual',
        url: 'https://etherpad.org/',
      },
    })) await writePackageJson(parsedPackageJSON);

    if (parsedPackageJSON.scripts == null) parsedPackageJSON.scripts = {};
    if (checkEntries(parsedPackageJSON.scripts, {
      'lint': 'eslint .',
      'lint:fix': 'eslint --fix .',
    })) await writePackageJson(parsedPackageJSON);
  }

  if (!files.includes('package-lock.json')) {
    logger.warn('package-lock.json not found');
    if (!autoFix) {
      logger.warn('Run npm install in the plugin folder and commit the package-lock.json file.');
    }
  }

  const fillTemplate = async (templateFilename, outputFilename) => {
    const contents = (await fsp.readFile(templateFilename, 'utf8'))
        .replace(/\[name of copyright owner\]/g, execSync('git config user.name'))
        .replace(/\[plugin_name\]/g, pluginName)
        .replace(/\[yyyy\]/g, new Date().getFullYear());
    await fsp.writeFile(outputFilename, contents);
  };

  if (!readMeFileName) {
    logger.warn('README.md file not found, please create');
    if (autoFix) {
      logger.info('Autofixing missing README.md file');
      logger.info('please edit the README.md file further to include plugin specific details.');
      await fillTemplate('src/bin/plugins/lib/README.md', `${pluginPath}/README.md`);
    }
  }

  if (!files.includes('CONTRIBUTING') && !files.includes('CONTRIBUTING.md')) {
    logger.warn('CONTRIBUTING.md file not found, please create');
    if (autoFix) {
      logger.info('Autofixing missing CONTRIBUTING.md file, please edit the CONTRIBUTING.md ' +
                  'file further to include plugin specific details.');
      await fillTemplate('src/bin/plugins/lib/CONTRIBUTING.md', `${pluginPath}/CONTRIBUTING.md`);
    }
  }


  if (readMeFileName) {
    let readme =
        await fsp.readFile(`${pluginPath}/${readMeFileName}`, {encoding: 'utf8', flag: 'r'});
    if (!readme.toLowerCase().includes('license')) {
      logger.warn('No license section in README');
      if (autoFix) {
        logger.warn('Please add License section to README manually.');
      }
    }
    // eslint-disable-next-line max-len
    const publishBadge = `![Publish Status](https://github.com/ether/${pluginName}/workflows/Node.js%20Package/badge.svg)`;
    // eslint-disable-next-line max-len
    const testBadge = `![Backend Tests Status](https://github.com/ether/${pluginName}/workflows/Backend%20tests/badge.svg)`;
    if (readme.toLowerCase().includes('travis')) {
      logger.warn('Remove Travis badges');
    }
    if (!readme.includes('workflows/Node.js%20Package/badge.svg')) {
      logger.warn('No Github workflow badge detected');
      if (autoFix) {
        readme = `${publishBadge} ${testBadge}\n\n${readme}`;
        // write readme to file system
        await fsp.writeFile(`${pluginPath}/${readMeFileName}`, readme);
        logger.info('Wrote Github workflow badges to README');
      }
    }
  }

  if (!files.includes('LICENSE') && !files.includes('LICENSE.md')) {
    logger.warn('LICENSE file not found, please create');
    if (autoFix) {
      logger.info('Autofixing missing LICENSE file (Apache 2.0).');
      await fsp.copyFile('src/bin/plugins/lib/LICENSE', `${pluginPath}/LICENSE`);
    }
  }

  if (!files.includes('.gitignore')) {
    logger.warn('.gitignore file not found, please create.  .gitignore files are useful to ' +
                 "ensure files aren't incorrectly commited to a repository.");
    if (autoFix) {
      logger.info('Autofixing missing .gitignore file');
      const gitignore =
          await fsp.readFile('src/bin/plugins/lib/gitignore', {encoding: 'utf8', flag: 'r'});
      await fsp.writeFile(`${pluginPath}/.gitignore`, gitignore);
    }
  } else {
    let gitignore =
        await fsp.readFile(`${pluginPath}/.gitignore`, {encoding: 'utf8', flag: 'r'});
    if (!gitignore.includes('node_modules/')) {
      logger.warn('node_modules/ missing from .gitignore');
      if (autoFix) {
        gitignore += 'node_modules/';
        await fsp.writeFile(`${pluginPath}/.gitignore`, gitignore);
      }
    }
  }

  // if we include templates but don't have translations...
  if (files.includes('templates') && !files.includes('locales')) {
    logger.warn('Translations not found, please create.  ' +
                 'Translation files help with Etherpad accessibility.');
  }


  if (files.includes('.ep_initialized')) {
    logger.warn(
        '.ep_initialized found, please remove.  .ep_initialized should never be commited to git ' +
        'and should only exist once the plugin has been executed one time.');
    if (autoFix) {
      logger.info('Autofixing incorrectly existing .ep_initialized file');
      await fsp.unlink(`${pluginPath}/.ep_initialized`);
    }
  }

  if (files.includes('npm-debug.log')) {
    logger.warn('npm-debug.log found, please remove.  npm-debug.log should never be commited to ' +
                 'your repository.');
    if (autoFix) {
      logger.info('Autofixing incorrectly existing npm-debug.log file');
      await fsp.unlink(`${pluginPath}/npm-debug.log`);
    }
  }

  if (files.includes('static')) {
    const staticFiles = await fsp.readdir(`${pluginPath}/static`);
    if (!staticFiles.includes('tests')) {
      logger.warn('Test files not found, please create tests.  https://github.com/ether/etherpad-lite/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');
    }
  } else {
    logger.warn('Test files not found, please create tests.  https://github.com/ether/etherpad-lite/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');
  }

  // Install dependencies so we can run ESLint. This should also create or update package-lock.json
  // if autoFix is enabled.
  const npmInstall = `npm install${autoFix ? '' : ' --no-package-lock'}`;
  execSync(npmInstall, {stdio: 'inherit'});
  // Create the ep_etherpad-lite symlink if necessary. This must be done after running `npm install`
  // because that command nukes the symlink.
  try {
    const d = await fsp.realpath(path.join(pluginPath, 'node_modules/ep_etherpad-lite'));
    assert.equal(d, epSrcDir);
  } catch (err) {
    execSync(`${npmInstall} --no-save ep_etherpad-lite@file:${epSrcDir}`, {stdio: 'inherit'});
  }
  // linting begins
  try {
    logger.info('Linting...');
    const lintCmd = autoFix ? 'npx eslint --fix .' : 'npx eslint';
    execSync(lintCmd, {stdio: 'inherit'});
  } catch (e) {
    // it is gonna throw an error anyway
    logger.info('Manual linting probably required, check with: npm run lint');
  }
  // linting ends.

  if (autoFix) {
    const unchanged = JSON.parse(execSync(
        'untracked=$(git ls-files -o --exclude-standard) || exit 1; ' +
        'git diff-files --quiet && [ -z "$untracked" ] && echo true || echo false'));
    if (!unchanged) {
      // Display a diff of changes. Git doesn't diff untracked files, so they must be added to the
      // index. Use a temporary index file to avoid modifying Git's default index file.
      execSync('git read-tree HEAD; git add -A && git diff-index -p --cached HEAD && echo ""', {
        env: {...process.env, GIT_INDEX_FILE: '.git/checkPlugin.index'},
        stdio: 'inherit',
      });
      await fsp.unlink(`${pluginPath}/.git/checkPlugin.index`);

      const commitCmd = [
        'git add -A',
        'git commit -m "autofixes from Etherpad checkPlugin.js"',
      ].join(' && ');
      if (autoCommit) {
        logger.info('Committing changes...');
        execSync(commitCmd, {stdio: 'inherit'});
      } else {
        logger.info('Fixes applied. Check the above git diff then run the following command:');
        logger.info(`(cd node_modules/${pluginName} && ${commitCmd})`);
      }
      const pushCmd = 'git push';
      if (autoPush) {
        logger.info('Pushing new commit...');
        execSync(pushCmd, {stdio: 'inherit'});
      } else {
        logger.info('Changes committed. To push, run the following command:');
        logger.info(`(cd node_modules/${pluginName} && ${pushCmd})`);
      }
    } else {
      logger.info('No changes.');
    }
  }

  logger.info('Finished');
})();
