/**
 * @license
 * Copyright Google Inc. All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */
// tslint:disable:no-implicit-dependencies
import { logging } from '@angular-devkit/core';
import { execSync } from 'child_process';
import { packages } from '../lib/packages';


const blacklist = [
  '9ce1aed331ad0742463b587f1f5555486ccc202f',
  'de7a44f23514594274394322adaf40ac87c38d8b',
  '21d87e93955b12d137417a2bb0536d7faef3ad07',
  '76a3ec3fea0482e15851dda1c61bb90b8c643bb7',
  '09d019e73308f6914a94f99284f7d54063da420e',
  '012672161087a05ae5ecffbed5d1ee307ce1e0ad',
  'dd39597b6a061100cce16494613332368b0f2553',
  'a11dddf454590c8df7e8fbc500e589eafbcb48fe',
  '9a9bc00a453988469b12a434372021a812e11223',
  '167f6fb95843e80a650ff948361c8c24bfc302d8',
  'fb1c2af810d069229760800c2f539e8a764fe1eb',
  '7ba94c8084eb65407abd5531dcbb7275401969c9',
  '8475b3dadba3d50a96a2f876188bbf78d55039aa',
];


export enum Scope {
  MustHave = 0,
  MustNotHave = 1,
  Either = 2,
}

export const types: { [t: string]: { description: string, scope: Scope } } = {
  // Types that can contain both a scope or no scope.
  'docs': {
    description: 'Documentation only changes.',
    scope: Scope.Either,
  },
  'refactor': {
    description: 'A code change that neither fixes a bug nor adds a feature',
    scope: Scope.Either,
  },
  'style': {
    description: 'Changes that do not affect the meaning of the code (white-space, formatting, '
               + 'missing semi-colons, etc).',
    scope: Scope.Either,
  },
  'test': {
    description: 'Adding missing tests or correcting existing tests.',
    scope: Scope.Either,
  },

  // Types that MUST contain a scope.
  'feat': {
    description: 'A new feature.',
    scope: Scope.MustHave,
  },
  'fix': {
    description: 'A bug fix.',
    scope: Scope.MustHave,
  },

  // Types that MUST NOT contain a scope.
  'build': {
    description: 'Changes that affect the build system or external dependencies.',
    scope: Scope.MustNotHave,
  },
  'revert': {
    description: 'A git commit revert. The description must include the original commit message.',
    scope: Scope.MustNotHave,
  },
  'ci': {
    description: 'Changes to our CI configuration files and scripts.',
    scope: Scope.MustNotHave,
  },
  'release': {
    description: 'A release commit. Must only include version changes.',
    scope: Scope.MustNotHave,
  },
};


export interface ValidateCommitsOptions {
  ci?: boolean;
  base?: string;
  head?: string;
}


export default function (argv: ValidateCommitsOptions, logger: logging.Logger) {
  logger.info('Getting merge base...');

  const prNumber = process.env['CIRCLE_PR_NUMBER'] || '';
  let baseSha = '';
  let sha = '';

  if (prNumber) {
    const url = `https://api.github.com/repos/angular/angular-cli/pulls/${prNumber}`;
    const prJson = JSON.parse(execSync(`curl "${url}"`, {
      stdio: ['ignore', 'pipe', 'ignore'],
      encoding: 'utf8',
    }).toString());
    baseSha = prJson['base']['sha'];
    sha = prJson['head']['sha'];
  } else if (argv.base) {
    baseSha = argv.base;
    sha = argv.head || 'HEAD';
  } else {
    const parentRemote = process.env['GIT_REMOTE'] ? process.env['GIT_REMOTE'] + '/' : '';
    const parentBranch = process.env['GIT_BRANCH'] || 'master';
    baseSha = execSync(`git merge-base --fork-point "${parentRemote}${parentBranch}"`)
      .toString().trim();
    sha = 'HEAD';
  }

  logger.createChild('sha').info(`Base: ${baseSha}\nHEAD: ${sha}`);

  const log = execSync(`git log --oneline "${baseSha}..${sha}"`).toString().trim();
  logger.debug('Commits:');
  logger.createChild('commits').debug(log);
  logger.debug('');

  const commits = log.split(/\n/)
    .map(i => i.match(/(^[0-9a-f]+) (.+)$/))
    .map(x => x ? Array.from(x).slice(1) : null)
    .filter(x => !!x) as [string, string][];
  logger.info(`Found ${commits.length} commits...`);

  const output = logger.createChild('check');
  let invalidCount = 0;

  function _invalid(sha: string, message: string, error: string) {
    invalidCount++;
    output.error(`The following commit ${error}:`);
    output.error(`  ${sha} ${message}`);
  }

  for (const [sha, message] of commits) {
    if (blacklist.find(i => i.startsWith(sha))) {
      // Some commits are better ignored.
      continue;
    }

    const subject = message.match(/^([^:(]+)(?:\((.*?)\))?:/);
    if (!subject) {
      _invalid(sha, message, 'does not have a subject');
      continue;
    }

    const [type, scope] = subject.slice(1);
    if (!(type in types)) {
      _invalid(sha, message, 'has an unknown type. You can use wip: to avoid this.');
      continue;
    }
    switch (types[type].scope) {
      case Scope.Either:
        if (scope && !packages[scope]) {
          _invalid(sha, message, 'has a scope that does not exist');
          continue;
        }
        break;

      case Scope.MustHave:
        if (!scope) {
          _invalid(sha, message, 'should always have a scope');
          continue;
        }
        if (!packages[scope]) {
          _invalid(sha, message, 'has a scope that does not exist');
          continue;
        }
        break;

      case Scope.MustNotHave:
        if (scope) {
          _invalid(sha, message, 'should not have a scope');
          continue;
        }
        break;
    }

    // Custom validation.
    if (type == 'release') {
      if (argv.ci && commits.length > 1) {
        _invalid(sha, message, 'release should always be alone in a PR');
        continue;
      }
    } else if (type == 'wip') {
      if (argv.ci) {
        _invalid(sha, message, 'wip are not allowed in a PR');
      }
    }
  }

  if (invalidCount > 0) {
    logger.fatal(`${invalidCount} commits were found invalid...`);
  } else {
    logger.info('All green. Thank you, come again.');
  }

  return invalidCount;
}
