/**
 * @license
 * Copyright Google LLC 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
 */
import {error} from '../utils/console';

import {getCommitMessageConfig} from './config';

/** Options for commit message validation. */
export interface ValidateCommitMessageOptions {
  disallowSquash?: boolean;
  nonFixupCommitHeaders?: string[];
}

const FIXUP_PREFIX_RE = /^fixup! /i;
const GITHUB_LINKING_RE = /((closed?s?)|(fix(es)?(ed)?)|(resolved?s?))\s\#(\d+)/ig;
const SQUASH_PREFIX_RE = /^squash! /i;
const REVERT_PREFIX_RE = /^revert:? /i;
const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/;
const COMMIT_HEADER_RE = /^(.*)/i;
const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/;
const COMMIT_BODY_URL_LINE_RE = /^https?:\/\/.*$/;

/** Parse a full commit message into its composite parts. */
export function parseCommitMessage(commitMsg: string) {
  let header = '';
  let body = '';
  let bodyWithoutLinking = '';
  let type = '';
  let scope = '';
  let subject = '';

  if (COMMIT_HEADER_RE.test(commitMsg)) {
    header = COMMIT_HEADER_RE.exec(commitMsg)![1]
                 .replace(FIXUP_PREFIX_RE, '')
                 .replace(SQUASH_PREFIX_RE, '');
  }
  if (COMMIT_BODY_RE.test(commitMsg)) {
    body = COMMIT_BODY_RE.exec(commitMsg)![1];
    bodyWithoutLinking = body.replace(GITHUB_LINKING_RE, '');
  }

  if (TYPE_SCOPE_RE.test(header)) {
    const parsedCommitHeader = TYPE_SCOPE_RE.exec(header)!;
    type = parsedCommitHeader[1];
    scope = parsedCommitHeader[2];
    subject = parsedCommitHeader[3];
  }
  return {
    header,
    body,
    bodyWithoutLinking,
    type,
    scope,
    subject,
    isFixup: FIXUP_PREFIX_RE.test(commitMsg),
    isSquash: SQUASH_PREFIX_RE.test(commitMsg),
    isRevert: REVERT_PREFIX_RE.test(commitMsg),
  };
}

/** Validate a commit message against using the local repo's config. */
export function validateCommitMessage(
    commitMsg: string, options: ValidateCommitMessageOptions = {}) {
  function printError(errorMessage: string) {
    error(
        `INVALID COMMIT MSG: \n` +
        `${'─'.repeat(40)}\n` +
        `${commitMsg}\n` +
        `${'─'.repeat(40)}\n` +
        `ERROR: \n` +
        `  ${errorMessage}` +
        `\n\n` +
        `The expected format for a commit is: \n` +
        `<type>(<scope>): <subject>\n\n<body>`);
  }

  const config = getCommitMessageConfig().commitMessage;
  const commit = parseCommitMessage(commitMsg);

  ////////////////////////////////////
  // Checking revert, squash, fixup //
  ////////////////////////////////////

  // All revert commits are considered valid.
  if (commit.isRevert) {
    return true;
  }

  // All squashes are considered valid, as the commit will be squashed into another in
  // the git history anyway, unless the options provided to not allow squash commits.
  if (commit.isSquash) {
    if (options.disallowSquash) {
      printError('The commit must be manually squashed into the target commit');
      return false;
    }
    return true;
  }

  // Fixups commits are considered valid, unless nonFixupCommitHeaders are provided to check
  // against. If `nonFixupCommitHeaders` is not empty, we check whether there is a corresponding
  // non-fixup commit (i.e. a commit whose header is identical to this commit's header after
  // stripping the `fixup! ` prefix), otherwise we assume this verification will happen in another
  // check.
  if (commit.isFixup) {
    if (options.nonFixupCommitHeaders && !options.nonFixupCommitHeaders.includes(commit.header)) {
      printError(
          'Unable to find match for fixup commit among prior commits: ' +
          (options.nonFixupCommitHeaders.map(x => `\n      ${x}`).join('') || '-'));
      return false;
    }

    return true;
  }

  ////////////////////////////
  // Checking commit header //
  ////////////////////////////
  if (commit.header.length > config.maxLineLength) {
    printError(`The commit message header is longer than ${config.maxLineLength} characters`);
    return false;
  }

  if (!commit.type) {
    printError(`The commit message header does not match the expected format.`);
    return false;
  }

  if (!config.types.includes(commit.type)) {
    printError(`'${commit.type}' is not an allowed type.\n => TYPES: ${config.types.join(', ')}`);
    return false;
  }

  if (commit.scope && !config.scopes.includes(commit.scope)) {
    printError(
        `'${commit.scope}' is not an allowed scope.\n => SCOPES: ${config.scopes.join(', ')}`);
    return false;
  }

  // Commits with the type of `release` do not require a commit body.
  if (commit.type === 'release') {
    return true;
  }

  //////////////////////////
  // Checking commit body //
  //////////////////////////

  if (!config.minBodyLengthTypeExcludes?.includes(commit.type) &&
      commit.bodyWithoutLinking.trim().length < config.minBodyLength) {
    printError(`The commit message body does not meet the minimum length of ${
        config.minBodyLength} characters`);
    return false;
  }

  const bodyByLine = commit.body.split('\n');
  const lineExceedsMaxLength = bodyByLine.some(line => {
    // Check if any line exceeds the max line length limit. The limit is ignored for
    // lines that just contain an URL (as these usually cannot be wrapped or shortened).
    return line.length > config.maxLineLength && !COMMIT_BODY_URL_LINE_RE.test(line);
  });

  if (lineExceedsMaxLength) {
    printError(
        `The commit message body contains lines greater than ${config.maxLineLength} characters`);
    return false;
  }

  return true;
}
