#!/usr/bin/env node

/* eslint-disable no-restricted-globals, no-console */

const path = require('path');
const { promises: fs } = require('fs');
const util = require('util');

const chalk = require('chalk');
const inquirer = require('inquirer');
const lodash = require('lodash');
const glob = util.promisify(require('glob'));
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const hotConfig = require('../hot.config');

const isTTY = process.stdin.isTTY;

const changelogMarkDownMark = '<!-- UNVERSIONED -->';
const changelogPath = path.join(__dirname, '../CHANGELOG.md');
const changelogsDirectoryName = '.changelogs';

// The order of the items affects the order of the categories that are generated in the CHANGELOG.md file.
const changelogEntryTypes = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'];
const changelogFrameworkTypes = ['none', 'react', 'vue', 'angular'];
const changelogIssuesOriginTypes = ['private', 'public'];

const uppercaseFirstLetter = s => s.slice(0, 1).toUpperCase() + s.slice(1);

const entryDestination = name =>
  path.relative(
    process.cwd(),
    path.join(__dirname, `../${changelogsDirectoryName}/${name}.json`)
  );

const fileExists = p => fs.access(p).then(() => true).catch(() => false);

const stringifyChangelogEntryObject = ({ issuesOrigin, title, issueOrPR, framework, breaking }) => {
  const breakingFragment = breaking ? '**Breaking change**: ' : '';
  const frameworkFragment = framework === 'none' ? '' : `${uppercaseFirstLetter(framework)}: `;
  const linkType = issuesOrigin === 'private' ? 'pull' : 'issues';
  const issueLinkFragment =
    `[#${issueOrPR}](https://github.com/handsontable/handsontable/${linkType}/${issueOrPR})`;

  return `${breakingFragment}${frameworkFragment}${title} ${issueLinkFragment}`;
};

const askToProceedOrExit = async(message = 'Proceed?') => {
  const { shouldProceed } = isTTY ? await inquirer.prompt({
    name: 'shouldProceed',
    type: 'confirm',
    message,
    default: true
  }) : true;

  if (shouldProceed === false) {
    process.exit(1);
  }
};

/**
 * Checks if the changelog entry has all the necessary properties. Returns a
 * new object with only the changelog entry properties that can be safely
 * stringified and put into a file. If the input object is missing properties
 * or they're in invalid format, throws an error.
 *
 * @param {object} input A valid changelog entry object.
 * @returns {object} The `input` object but only with valid changelog entry properties.
 */
const assertChangelogEntryFormat = (input) => {
  if (typeof input !== 'object' || input === null) {
    throw new TypeError(`input must be an object and not be null (got: ${input})`);
  }

  if (typeof input.issuesOrigin !== 'string' || !changelogIssuesOriginTypes.includes(input.issuesOrigin)) {
    throw new TypeError(
      `input.issuesOrigin must be one of: ${changelogIssuesOriginTypes} (got: ${input.issuesOrigin})`);
  }

  if (typeof input.title !== 'string' || input.title.length === 0) {
    throw new TypeError(`input.title must be a non-empty string (got: ${input.title})`);
  }

  if (typeof input.type !== 'string' || !changelogEntryTypes.includes(input.type)) {
    throw new TypeError(`input.type must be one of: ${changelogEntryTypes} (got: ${input.type})`);
  }

  if (typeof input.issueOrPR !== 'number' || Number.isNaN(input.issueOrPR)) {
    throw new TypeError(`input.issueOrPR must be a non-NaN number (got: ${input.issueOrPR})`);
  }

  if (typeof input.breaking !== 'boolean') {
    throw new TypeError(`input.breaking must be a boolean (got: ${typeof input.breaking})`);
  }

  if (typeof input.framework !== 'string' || !changelogFrameworkTypes.includes(input.framework)) {
    throw new TypeError(`input.framework must be one of: ${changelogFrameworkTypes} (got: ${input.framework})`);
  }

  return {
    issuesOrigin: input.issuesOrigin,
    title: input.title,
    type: input.type,
    issueOrPR: input.issueOrPR,
    breaking: input.breaking,
    framework: input.framework
  };
};

const createEntryCommand = async(args) => {
  const argTitle = args.title ? args.title.join(' ') : undefined;
  const answers = isTTY ? await inquirer.prompt([
    {
      name: 'issuesOrigin',
      type: 'list',
      message: 'Issue\'s origin',
      choices: changelogIssuesOriginTypes,
      default: changelogIssuesOriginTypes[0],
      when: typeof args.issuesOrigin === 'undefined'
    },
    {
      name: 'title',
      type: 'input',
      message: 'Title of the entry (e.g. "Fixed an issue with...")',
      validate: s => s.length > 0,
      default: argTitle,
      when: typeof argTitle === 'undefined'
    },
    {
      name: 'issueOrPR',
      type: 'input',
      message: ({ issuesOrigin }) => {
        if (issuesOrigin === 'private') {
          return '# of the related public PR (e.g. 512)';
        }

        return '# of the related public issue (e.g. 512)';
      },
      validate: s => (Number.isInteger(s) ? true : 'The provided input is not a number'),
      filter: s => (Number.isNaN(parseInt(s, 10)) ? '' : parseInt(s, 10)),
      when: typeof args.issueOrPR === 'undefined'
    },
    {
      name: 'breaking',
      type: 'confirm',
      message: 'Is this a breaking change?',
      default: false,
      when: typeof args.breaking === 'undefined'
    },
    {
      name: 'type',
      type: 'list',
      message: 'Type of the change',
      choices: changelogEntryTypes,

      default: ({ title }) =>
        // Attempts to find a suitable default choice based on the title.
        // e.g. the title "Fixed every bug" would return the type "fixed",
        // and "Added 10 features" would return "added".
        changelogEntryTypes.find(
          type => (title || argTitle).toLowerCase().includes(type)
        ) || 'changed',

      when: typeof args.framework === 'undefined'
    },
    {
      name: 'framework',
      type: 'list',
      message: 'Framework',
      choices: changelogFrameworkTypes,
      default: 'none',
      when: typeof args.framework === 'undefined'
    }
  ]) : {};

  const changelogEntry = assertChangelogEntryFormat({ ...args, title: argTitle, ...answers });

  const filename = changelogEntry.issueOrPR.toString();
  const destination = entryDestination(filename);

  console.log(`
${chalk.dim(
    `Your entry will be saved in ${chalk.reset.blue.underline(destination)}.
This is how it will look like compiled to markdown in ${chalk.reset.blue.underline('CHANGELOG.md')}:`
  )}

${chalk.yellow.italic(`### ${uppercaseFirstLetter(changelogEntry.type)}
- ${stringifyChangelogEntryObject(changelogEntry)}`)}
`);

  await askToProceedOrExit();

  if (await fileExists(destination)) {
    console.log(`The file \`${destination}\` already exists.`);

    await askToProceedOrExit('Overwrite?');
  }

  await fs.writeFile(destination, `${JSON.stringify(changelogEntry, null, 2)}\n`);

  console.log();
  console.log(`Entry created! Run ${chalk.blackBright(`\`rm ${destination}\``)} to undo.`);
};

const consumeCommand = async(args) => {
  const { dryRun } = args;

  const formattedDefault = args.date === undefined
    ? hotConfig.HOT_RELEASE_DATE.split('/').reverse().join('-')
    : args.date;

  const { date } = isTTY ? await inquirer.prompt([
    {
      name: 'date',
      type: 'input',
      message: 'When should the version be released?',
      default: formattedDefault
    }
  ]) : { date: formattedDefault };

  const existingChangelogContents = await fs.readFile(changelogPath, 'utf8');

  if (!existingChangelogContents.includes(changelogMarkDownMark)) {
    throw new Error(
      `The existing changelog file (${
        changelogPath
      }) does not include the mark (\`${
        changelogMarkDownMark
      }\`) to put new contents into.`
    );
  }

  const changelogEntryFiles =
    await glob('*.json', {
      absolute: true,
      cwd: path.join(__dirname, '..', changelogsDirectoryName)
    });

  const ungroupedEntries = (await Promise.all(changelogEntryFiles.map(p => fs.readFile(p, 'utf8'))))
    .map(contents => assertChangelogEntryFormat(JSON.parse(contents)));

  const groupedEntries = lodash.groupBy(ungroupedEntries, 'type');
  const groupedPairs = lodash.sortBy(lodash
    .toPairs(groupedEntries), ([key]) => changelogEntryTypes.indexOf(key));

  const compiledEntries = groupedPairs
    .map(([type, entries]) =>
      `${`### ${uppercaseFirstLetter(type)}\n`}${
        lodash.chain(entries)
          .partition('breaking')
          .map(
            x => lodash.sortBy(x, ({ framework }) => (framework === 'none' ? 0 : framework.length))
          )
          .flatten()
          .map(entry => `- ${stringifyChangelogEntryObject(entry)}`)
          .join('\n')
          .value()
      }`
    )
    .join('\n\n');

  const compiled = `
## [${hotConfig.HOT_VERSION}] - ${date}

${compiledEntries}
`.trim();

  console.log(`
${chalk.dim(
    'You are about to update the changelog file, that will include the following text in the appropriate place:'
  )}

${chalk.yellow.italic(compiled)}
`);

  if (dryRun) {
    console.log('Dry run, skipping write.');

    process.exit(0);
  }

  await askToProceedOrExit(`Write the new ${path.basename(changelogPath)} and delete the changelog entry .json files?`);

  const newChangelogContents =
    existingChangelogContents.replace(
      changelogMarkDownMark,
      `${changelogMarkDownMark}\n\n${compiled}`
    );

  console.log();
  console.log(chalk.dim('Writing the new changelog...'));
  await fs.writeFile(changelogPath, newChangelogContents);

  console.log(chalk.dim('Deleting changelog entries...'));
  await Promise.all(changelogEntryFiles.map(p => fs.unlink(p)));

  console.log();
  console.log(`Changelog updated! Run ${
    chalk.blackBright(`\`git checkout ${path.relative(process.cwd(), changelogPath)
    } ${path.relative(
      process.cwd(),
      path.join(__dirname, '..', changelogsDirectoryName))}\``)
  } to undo.`);
};

const syncCommand = async(args) => {
  const { dryRun } = args;
  const text = await fs.readFile(changelogPath, 'utf8');

  const version = args.version || text.match(/^## \[([^\]]+)\]/m)?.[1];

  if (!version) {
    throw new Error('No versioned sections found in CHANGELOG.md');
  }

  const start = text.indexOf(`## [${version}]`);

  if (start === -1) {
    throw new Error(`Version ${version} not found in CHANGELOG.md`);
  }

  const nextStart = text.indexOf('\n## [', start + 1);
  const end = nextStart === -1 ? text.length : nextStart;
  const section = text.slice(start, end);

  // Parse the section into type buckets
  const buckets = {};
  let curType = null;

  section.split('\n').forEach((line) => {
    const m = line.match(/^### (\w+)/);

    if (m) {
      curType = m[1].toLowerCase();
      buckets[curType] = buckets[curType] || [];
    } else if (curType && line.startsWith('- ')) {
      buckets[curType].push(line);
    }
  });

  // Load entries, skipping ones already present in this section
  const entryFiles = await glob('*.json', {
    absolute: true,
    cwd: path.join(__dirname, '..', changelogsDirectoryName)
  });

  if (!entryFiles.length) {
    console.log('No changelog entries found in the .changelogs directory.');

    return;
  }

  const newEntries = (await Promise.all(entryFiles.map(p => fs.readFile(p, 'utf8'))))
    .map(c => assertChangelogEntryFormat(JSON.parse(c)))
    .filter(e => !section.includes(`/${e.issueOrPR})`));

  if (!newEntries.length) {
    console.log(`All changelog entries are already present in version ${version}.`);

    if (!dryRun) {
      await Promise.all(entryFiles.map(p => fs.unlink(p)));
    }

    return;
  }

  console.log(chalk.dim(
    `\nSyncing into [${version}] in ${chalk.reset.blue.underline(path.basename(changelogPath))}:\n`
  ));
  newEntries.forEach(e => console.log(chalk.yellow.italic(`  - ${stringifyChangelogEntryObject(e)}`)));
  console.log();

  if (dryRun) {
    console.log('Dry run, skipping write.');

    process.exit(0);
  }

  await askToProceedOrExit(`Write the new ${path.basename(changelogPath)} and delete the changelog entry .json files?`);

  // Merge entries into buckets
  newEntries.forEach((e) => {
    (buckets[e.type] = buckets[e.type] || []).push(`- ${stringifyChangelogEntryObject(e)}`);
  });

  // Re-serialize the version section
  const newSection = `${section.split('\n')[0]}\n${
    changelogEntryTypes
      .filter(t => buckets[t]?.length)
      .map(t => `\n### ${uppercaseFirstLetter(t)}\n${buckets[t].join('\n')}\n`)
      .join('')
  }`;

  console.log();
  console.log(chalk.dim('Writing the new changelog...'));
  await fs.writeFile(changelogPath, text.slice(0, start) + newSection + text.slice(end));

  console.log(chalk.dim('Deleting changelog entries...'));
  await Promise.all(entryFiles.map(p => fs.unlink(p)));

  console.log();
  console.log(`Changelog updated! Run ${
    chalk.blackBright(`\`git checkout ${path.relative(process.cwd(), changelogPath)
    } ${path.relative(process.cwd(), path.join(__dirname, '..', changelogsDirectoryName))}\``)
  } to undo.`);
};

// eslint-disable-next-line no-unused-expressions
yargs(hideBin(process.argv))
  .command('entry [title..]', 'create a new changelog entry', (y) => {
    return y
      .option('type', { choices: changelogEntryTypes })
      .option('issue', { type: 'number' })
      .option('breaking', { type: 'boolean' })
      .option('framework', { choices: changelogFrameworkTypes });
  }, argv => createEntryCommand(argv))
  .command('consume', `compile all \`${changelogsDirectoryName}/*.json\` files into changelog.md`, (y) => {
    return y
      .option('date', { type: 'string' })
      .option('dry-run', { type: 'boolean' });
  }, argv => consumeCommand(argv))
  .command(
    'sync [version]',
    `sync all \`${changelogsDirectoryName}/*.json\` entries into an existing version section`,
    y => y.option('dry-run', { type: 'boolean' }),
    argv => syncCommand(argv)
  )
  .help()
  .demandCommand()
  .version(false)
  .argv;
