/**
 * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

import * as fs from 'graceful-fs';
import type {Config} from '@jest/types';
import type {MatcherFunctionWithState} from 'expect';
import type {FS as HasteFS} from 'jest-haste-map';
import {
  BOLD_WEIGHT,
  EXPECTED_COLOR,
  MatcherHintOptions,
  RECEIVED_COLOR,
  matcherErrorMessage,
  matcherHint,
  printWithType,
  stringify,
} from 'jest-matcher-utils';
import {EXTENSION, SnapshotResolver} from './SnapshotResolver';
import {
  PROPERTIES_ARG,
  SNAPSHOT_ARG,
  bReceivedColor,
  matcherHintFromConfig,
  noColor,
  printExpected,
  printPropertiesAndReceived,
  printReceived,
  printSnapshotAndReceived,
} from './printSnapshot';
import type {Context, MatchSnapshotConfig} from './types';
import {deepMerge, escapeBacktickString, serialize} from './utils';

export {addSerializer, getSerializers} from './plugins';
export {
  EXTENSION,
  buildSnapshotResolver,
  isSnapshotPath,
} from './SnapshotResolver';
export type {SnapshotResolver} from './SnapshotResolver';
export {default as SnapshotState} from './State';
export type {SnapshotMatchers} from './types';

const DID_NOT_THROW = 'Received function did not throw'; // same as toThrow
const NOT_SNAPSHOT_MATCHERS = `Snapshot matchers cannot be used with ${BOLD_WEIGHT(
  'not',
)}`;

const INDENTATION_REGEX = /^([^\S\n]*)\S/m;

// Display name in report when matcher fails same as in snapshot file,
// but with optional hint argument in bold weight.
const printSnapshotName = (
  concatenatedBlockNames = '',
  hint = '',
  count: number,
): string => {
  const hasNames = concatenatedBlockNames.length !== 0;
  const hasHint = hint.length !== 0;

  return `Snapshot name: \`${
    hasNames ? escapeBacktickString(concatenatedBlockNames) : ''
  }${hasNames && hasHint ? ': ' : ''}${
    hasHint ? BOLD_WEIGHT(escapeBacktickString(hint)) : ''
  } ${count}\``;
};

function stripAddedIndentation(inlineSnapshot: string) {
  // Find indentation if exists.
  const match = inlineSnapshot.match(INDENTATION_REGEX);
  if (!match || !match[1]) {
    // No indentation.
    return inlineSnapshot;
  }

  const indentation = match[1];
  const lines = inlineSnapshot.split('\n');
  if (lines.length <= 2) {
    // Must be at least 3 lines.
    return inlineSnapshot;
  }

  if (lines[0].trim() !== '' || lines[lines.length - 1].trim() !== '') {
    // If not blank first and last lines, abort.
    return inlineSnapshot;
  }

  for (let i = 1; i < lines.length - 1; i++) {
    if (lines[i] !== '') {
      if (lines[i].indexOf(indentation) !== 0) {
        // All lines except first and last should either be blank or have the same
        // indent as the first line (or more). If this isn't the case we don't
        // want to touch the snapshot at all.
        return inlineSnapshot;
      }

      lines[i] = lines[i].substring(indentation.length);
    }
  }

  // Last line is a special case because it won't have the same indent as others
  // but may still have been given some indent to line up.
  lines[lines.length - 1] = '';

  // Return inline snapshot, now at indent 0.
  inlineSnapshot = lines.join('\n');
  return inlineSnapshot;
}

const fileExists = (filePath: string, hasteFS: HasteFS): boolean =>
  hasteFS.exists(filePath) || fs.existsSync(filePath);

export const cleanup = (
  hasteFS: HasteFS,
  update: Config.SnapshotUpdateState,
  snapshotResolver: SnapshotResolver,
  testPathIgnorePatterns?: Config.ProjectConfig['testPathIgnorePatterns'],
): {
  filesRemoved: number;
  filesRemovedList: Array<string>;
} => {
  const pattern = `\\.${EXTENSION}$`;
  const files = hasteFS.matchFiles(pattern);
  let testIgnorePatternsRegex: RegExp | null = null;
  if (testPathIgnorePatterns && testPathIgnorePatterns.length > 0) {
    testIgnorePatternsRegex = new RegExp(testPathIgnorePatterns.join('|'));
  }

  const list = files.filter(snapshotFile => {
    const testPath = snapshotResolver.resolveTestPath(snapshotFile);

    // ignore snapshots of ignored tests
    if (testIgnorePatternsRegex && testIgnorePatternsRegex.test(testPath)) {
      return false;
    }

    if (!fileExists(testPath, hasteFS)) {
      if (update === 'all') {
        fs.unlinkSync(snapshotFile);
      }
      return true;
    }

    return false;
  });

  return {
    filesRemoved: list.length,
    filesRemovedList: list,
  };
};

export const toMatchSnapshot: MatcherFunctionWithState<Context> = function (
  received: unknown,
  propertiesOrHint?: object | string,
  hint?: string,
) {
  const matcherName = 'toMatchSnapshot';
  let properties;

  const length = arguments.length;
  if (length === 2 && typeof propertiesOrHint === 'string') {
    hint = propertiesOrHint;
  } else if (length >= 2) {
    if (typeof propertiesOrHint !== 'object' || propertiesOrHint === null) {
      const options: MatcherHintOptions = {
        isNot: this.isNot,
        promise: this.promise,
      };
      let printedWithType = printWithType(
        'Expected properties',
        propertiesOrHint,
        printExpected,
      );

      if (length === 3) {
        options.secondArgument = 'hint';
        options.secondArgumentColor = BOLD_WEIGHT;

        if (propertiesOrHint == null) {
          printedWithType +=
            "\n\nTo provide a hint without properties: toMatchSnapshot('hint')";
        }
      }

      throw new Error(
        matcherErrorMessage(
          matcherHint(matcherName, undefined, PROPERTIES_ARG, options),
          `Expected ${EXPECTED_COLOR('properties')} must be an object`,
          printedWithType,
        ),
      );
    }

    // Future breaking change: Snapshot hint must be a string
    // if (arguments.length === 3 && typeof hint !== 'string') {}

    properties = propertiesOrHint;
  }

  return _toMatchSnapshot({
    context: this,
    hint,
    isInline: false,
    matcherName,
    properties,
    received,
  });
};

export const toMatchInlineSnapshot: MatcherFunctionWithState<Context> =
  function (
    received: unknown,
    propertiesOrSnapshot?: object | string,
    inlineSnapshot?: string,
  ) {
    const matcherName = 'toMatchInlineSnapshot';
    let properties;

    const length = arguments.length;
    if (length === 2 && typeof propertiesOrSnapshot === 'string') {
      inlineSnapshot = propertiesOrSnapshot;
    } else if (length >= 2) {
      const options: MatcherHintOptions = {
        isNot: this.isNot,
        promise: this.promise,
      };
      if (length === 3) {
        options.secondArgument = SNAPSHOT_ARG;
        options.secondArgumentColor = noColor;
      }

      if (
        typeof propertiesOrSnapshot !== 'object' ||
        propertiesOrSnapshot === null
      ) {
        throw new Error(
          matcherErrorMessage(
            matcherHint(matcherName, undefined, PROPERTIES_ARG, options),
            `Expected ${EXPECTED_COLOR('properties')} must be an object`,
            printWithType(
              'Expected properties',
              propertiesOrSnapshot,
              printExpected,
            ),
          ),
        );
      }

      if (length === 3 && typeof inlineSnapshot !== 'string') {
        throw new Error(
          matcherErrorMessage(
            matcherHint(matcherName, undefined, PROPERTIES_ARG, options),
            'Inline snapshot must be a string',
            printWithType('Inline snapshot', inlineSnapshot, serialize),
          ),
        );
      }

      properties = propertiesOrSnapshot;
    }

    return _toMatchSnapshot({
      context: this,
      inlineSnapshot:
        inlineSnapshot !== undefined
          ? stripAddedIndentation(inlineSnapshot)
          : undefined,
      isInline: true,
      matcherName,
      properties,
      received,
    });
  };

const _toMatchSnapshot = (config: MatchSnapshotConfig) => {
  const {context, hint, inlineSnapshot, isInline, matcherName, properties} =
    config;
  let {received} = config;

  context.dontThrow && context.dontThrow();

  const {currentTestName, isNot, snapshotState} = context;

  if (isNot) {
    throw new Error(
      matcherErrorMessage(
        matcherHintFromConfig(config, false),
        NOT_SNAPSHOT_MATCHERS,
      ),
    );
  }

  if (snapshotState == null) {
    // Because the state is the problem, this is not a matcher error.
    // Call generic stringify from jest-matcher-utils package
    // because uninitialized snapshot state does not need snapshot serializers.
    throw new Error(
      `${matcherHintFromConfig(config, false)}\n\n` +
        'Snapshot state must be initialized' +
        `\n\n${printWithType('Snapshot state', snapshotState, stringify)}`,
    );
  }

  const fullTestName =
    currentTestName && hint
      ? `${currentTestName}: ${hint}`
      : currentTestName || ''; // future BREAKING change: || hint

  if (typeof properties === 'object') {
    if (typeof received !== 'object' || received === null) {
      throw new Error(
        matcherErrorMessage(
          matcherHintFromConfig(config, false),
          `${RECEIVED_COLOR(
            'received',
          )} value must be an object when the matcher has ${EXPECTED_COLOR(
            'properties',
          )}`,
          printWithType('Received', received, printReceived),
        ),
      );
    }

    const propertyPass = context.equals(received, properties, [
      context.utils.iterableEquality,
      context.utils.subsetEquality,
    ]);

    if (!propertyPass) {
      const key = snapshotState.fail(fullTestName, received);
      const matched = /(\d+)$/.exec(key);
      const count = matched === null ? 1 : Number(matched[1]);

      const message = () =>
        `${matcherHintFromConfig(config, false)}\n\n${printSnapshotName(
          currentTestName,
          hint,
          count,
        )}\n\n${printPropertiesAndReceived(
          properties,
          received,
          snapshotState.expand,
        )}`;

      return {
        message,
        name: matcherName,
        pass: false,
      };
    } else {
      received = deepMerge(received, properties);
    }
  }

  const result = snapshotState.match({
    error: context.error,
    inlineSnapshot,
    isInline,
    received,
    testName: fullTestName,
  });
  const {actual, count, expected, pass} = result;

  if (pass) {
    return {message: () => '', pass: true};
  }

  const message =
    expected === undefined
      ? () =>
          `${matcherHintFromConfig(config, true)}\n\n${printSnapshotName(
            currentTestName,
            hint,
            count,
          )}\n\n` +
          `New snapshot was ${BOLD_WEIGHT('not written')}. The update flag ` +
          'must be explicitly passed to write a new snapshot.\n\n' +
          'This is likely because this test is run in a continuous integration ' +
          '(CI) environment in which snapshots are not written by default.\n\n' +
          `Received:${actual.includes('\n') ? '\n' : ' '}${bReceivedColor(
            actual,
          )}`
      : () =>
          `${matcherHintFromConfig(config, true)}\n\n${printSnapshotName(
            currentTestName,
            hint,
            count,
          )}\n\n${printSnapshotAndReceived(
            expected,
            actual,
            received,
            snapshotState.expand,
          )}`;

  // Passing the actual and expected objects so that a custom reporter
  // could access them, for example in order to display a custom visual diff,
  // or create a different error message
  return {
    actual,
    expected,
    message,
    name: matcherName,
    pass: false,
  };
};

export const toThrowErrorMatchingSnapshot: MatcherFunctionWithState<Context> =
  function (received: unknown, hint?: string, fromPromise?: boolean) {
    const matcherName = 'toThrowErrorMatchingSnapshot';

    // Future breaking change: Snapshot hint must be a string
    // if (hint !== undefined && typeof hint !== string) {}

    return _toThrowErrorMatchingSnapshot(
      {
        context: this,
        hint,
        isInline: false,
        matcherName,
        received,
      },
      fromPromise,
    );
  };

export const toThrowErrorMatchingInlineSnapshot: MatcherFunctionWithState<Context> =
  function (received: unknown, inlineSnapshot?: string, fromPromise?: boolean) {
    const matcherName = 'toThrowErrorMatchingInlineSnapshot';

    if (inlineSnapshot !== undefined && typeof inlineSnapshot !== 'string') {
      const options: MatcherHintOptions = {
        expectedColor: noColor,
        isNot: this.isNot,
        promise: this.promise,
      };

      throw new Error(
        matcherErrorMessage(
          matcherHint(matcherName, undefined, SNAPSHOT_ARG, options),
          'Inline snapshot must be a string',
          printWithType('Inline snapshot', inlineSnapshot, serialize),
        ),
      );
    }

    return _toThrowErrorMatchingSnapshot(
      {
        context: this,
        inlineSnapshot:
          inlineSnapshot !== undefined
            ? stripAddedIndentation(inlineSnapshot)
            : undefined,
        isInline: true,
        matcherName,
        received,
      },
      fromPromise,
    );
  };

const _toThrowErrorMatchingSnapshot = (
  config: MatchSnapshotConfig,
  fromPromise?: boolean,
) => {
  const {context, hint, inlineSnapshot, isInline, matcherName, received} =
    config;

  context.dontThrow && context.dontThrow();

  const {isNot, promise} = context;

  if (!fromPromise) {
    if (typeof received !== 'function') {
      const options: MatcherHintOptions = {isNot, promise};

      throw new Error(
        matcherErrorMessage(
          matcherHint(matcherName, undefined, '', options),
          `${RECEIVED_COLOR('received')} value must be a function`,
          printWithType('Received', received, printReceived),
        ),
      );
    }
  }

  if (isNot) {
    throw new Error(
      matcherErrorMessage(
        matcherHintFromConfig(config, false),
        NOT_SNAPSHOT_MATCHERS,
      ),
    );
  }

  let error;

  if (fromPromise) {
    error = received;
  } else {
    try {
      received();
    } catch (e) {
      error = e;
    }
  }

  if (error === undefined) {
    // Because the received value is a function, this is not a matcher error.
    throw new Error(
      `${matcherHintFromConfig(config, false)}\n\n${DID_NOT_THROW}`,
    );
  }

  return _toMatchSnapshot({
    context,
    hint,
    inlineSnapshot,
    isInline,
    matcherName,
    received: error.message,
  });
};
