'use strict';

const chalk = require('chalk');
const util = require('util');
const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError');
const {getTestFlags} = require('./TestFlags');

if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
  // Inside the class equivalence tester, we have a custom environment, let's
  // require that instead.
  require('./spec-equivalence-reporter/setupTests.js');
} else {
  const errorMap = require('../error-codes/codes.json');

  // By default, jest.spyOn also calls the spied method.
  const spyOn = jest.spyOn;
  const noop = jest.fn;

  // Spying on console methods in production builds can mask errors.
  // This is why we added an explicit spyOnDev() helper.
  // It's too easy to accidentally use the more familiar spyOn() helper though,
  // So we disable it entirely.
  // Spying on both dev and prod will require using both spyOnDev() and spyOnProd().
  global.spyOn = function () {
    throw new Error(
      'Do not use spyOn(). ' +
        'It can accidentally hide unexpected errors in production builds. ' +
        'Use spyOnDev(), spyOnProd(), or spyOnDevAndProd() instead.'
    );
  };

  if (process.env.NODE_ENV === 'production') {
    global.spyOnDev = noop;
    global.spyOnProd = spyOn;
    global.spyOnDevAndProd = spyOn;
  } else {
    global.spyOnDev = spyOn;
    global.spyOnProd = noop;
    global.spyOnDevAndProd = spyOn;
  }

  expect.extend({
    ...require('./matchers/reactTestMatchers'),
    ...require('./matchers/toThrow'),
    ...require('./matchers/toWarnDev'),
  });

  // We have a Babel transform that inserts guards against infinite loops.
  // If a loop runs for too many iterations, we throw an error and set this
  // global variable. The global lets us detect an infinite loop even if
  // the actual error object ends up being caught and ignored. An infinite
  // loop must always fail the test!
  beforeEach(() => {
    global.infiniteLoopError = null;
  });
  afterEach(() => {
    const error = global.infiniteLoopError;
    global.infiniteLoopError = null;
    if (error) {
      throw error;
    }
  });

  // TODO: Consider consolidating this with `yieldValue`. In both cases, tests
  // should not be allowed to exit without asserting on the entire log.
  const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => {
    const newMethod = function (format, ...args) {
      // Ignore uncaught errors reported by jsdom
      // and React addendums because they're too noisy.
      if (methodName === 'error' && shouldIgnoreConsoleError(format, args)) {
        return;
      }

      // Capture the call stack now so we can warn about it later.
      // The call stack has helpful information for the test author.
      // Don't throw yet though b'c it might be accidentally caught and suppressed.
      const stack = new Error().stack;
      unexpectedConsoleCallStacks.push([
        stack.slice(stack.indexOf('\n') + 1),
        util.format(format, ...args),
      ]);
    };

    console[methodName] = newMethod;

    return newMethod;
  };

  const flushUnexpectedConsoleCalls = (
    mockMethod,
    methodName,
    expectedMatcher,
    unexpectedConsoleCallStacks
  ) => {
    if (
      console[methodName] !== mockMethod &&
      !jest.isMockFunction(console[methodName])
    ) {
      throw new Error(
        `Test did not tear down console.${methodName} mock properly.`
      );
    }
    if (unexpectedConsoleCallStacks.length > 0) {
      const messages = unexpectedConsoleCallStacks.map(
        ([stack, message]) =>
          `${chalk.red(message)}\n` +
          `${stack
            .split('\n')
            .map(line => chalk.gray(line))
            .join('\n')}`
      );

      const message =
        `Expected test not to call ${chalk.bold(
          `console.${methodName}()`
        )}.\n\n` +
        'If the warning is expected, test for it explicitly by:\n' +
        `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` +
        `matcher, or...\n` +
        `2. Mock it out using ${chalk.bold(
          'spyOnDev'
        )}(console, '${methodName}') or ${chalk.bold(
          'spyOnProd'
        )}(console, '${methodName}'), and test that the warning occurs.`;

      throw new Error(`${message}\n\n${messages.join('\n\n')}`);
    }
  };

  const unexpectedErrorCallStacks = [];
  const unexpectedWarnCallStacks = [];

  const errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks);
  const warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks);

  const flushAllUnexpectedConsoleCalls = () => {
    flushUnexpectedConsoleCalls(
      errorMethod,
      'error',
      'toErrorDev',
      unexpectedErrorCallStacks
    );
    flushUnexpectedConsoleCalls(
      warnMethod,
      'warn',
      'toWarnDev',
      unexpectedWarnCallStacks
    );
    unexpectedErrorCallStacks.length = 0;
    unexpectedWarnCallStacks.length = 0;
  };

  const resetAllUnexpectedConsoleCalls = () => {
    unexpectedErrorCallStacks.length = 0;
    unexpectedWarnCallStacks.length = 0;
  };

  beforeEach(resetAllUnexpectedConsoleCalls);
  afterEach(flushAllUnexpectedConsoleCalls);

  if (process.env.NODE_ENV === 'production') {
    // In production, we strip error messages and turn them into codes.
    // This decodes them back so that the test assertions on them work.
    // 1. `ErrorProxy` decodes error messages at Error construction time and
    //    also proxies error instances with `proxyErrorInstance`.
    // 2. `proxyErrorInstance` decodes error messages when the `message`
    //    property is changed.
    const decodeErrorMessage = function (message) {
      if (!message) {
        return message;
      }
      const re = /error-decoder.html\?invariant=(\d+)([^\s]*)/;
      const matches = message.match(re);
      if (!matches || matches.length !== 3) {
        return message;
      }
      const code = parseInt(matches[1], 10);
      const args = matches[2]
        .split('&')
        .filter(s => s.startsWith('args[]='))
        .map(s => s.slice('args[]='.length))
        .map(decodeURIComponent);
      const format = errorMap[code];
      let argIndex = 0;
      return format.replace(/%s/g, () => args[argIndex++]);
    };
    const OriginalError = global.Error;
    // V8's Error.captureStackTrace (used in Jest) fails if the error object is
    // a Proxy, so we need to pass it the unproxied instance.
    const originalErrorInstances = new WeakMap();
    const captureStackTrace = function (error, ...args) {
      return OriginalError.captureStackTrace.call(
        this,
        originalErrorInstances.get(error) ||
          // Sometimes this wrapper receives an already-unproxied instance.
          error,
        ...args
      );
    };
    const proxyErrorInstance = error => {
      const proxy = new Proxy(error, {
        set(target, key, value, receiver) {
          if (key === 'message') {
            return Reflect.set(
              target,
              key,
              decodeErrorMessage(value),
              receiver
            );
          }
          return Reflect.set(target, key, value, receiver);
        },
      });
      originalErrorInstances.set(proxy, error);
      return proxy;
    };
    const ErrorProxy = new Proxy(OriginalError, {
      apply(target, thisArg, argumentsList) {
        const error = Reflect.apply(target, thisArg, argumentsList);
        error.message = decodeErrorMessage(error.message);
        return proxyErrorInstance(error);
      },
      construct(target, argumentsList, newTarget) {
        const error = Reflect.construct(target, argumentsList, newTarget);
        error.message = decodeErrorMessage(error.message);
        return proxyErrorInstance(error);
      },
      get(target, key, receiver) {
        if (key === 'captureStackTrace') {
          return captureStackTrace;
        }
        return Reflect.get(target, key, receiver);
      },
    });
    ErrorProxy.OriginalError = OriginalError;
    global.Error = ErrorProxy;
  }

  const expectTestToFail = async (callback, errorMsg) => {
    if (callback.length > 0) {
      throw Error(
        'Gated test helpers do not support the `done` callback. Return a ' +
          'promise instead.'
      );
    }
    try {
      const maybePromise = callback();
      if (
        maybePromise !== undefined &&
        maybePromise !== null &&
        typeof maybePromise.then === 'function'
      ) {
        await maybePromise;
      }
      // Flush unexpected console calls inside the test itself, instead of in
      // `afterEach` like we normally do. `afterEach` is too late because if it
      // throws, we won't have captured it.
      flushAllUnexpectedConsoleCalls();
    } catch (error) {
      // Failed as expected
      resetAllUnexpectedConsoleCalls();
      return;
    }
    throw Error(errorMsg);
  };

  const gatedErrorMessage = 'Gated test was expected to fail, but it passed.';
  global._test_gate = (gateFn, testName, callback) => {
    let shouldPass;
    try {
      const flags = getTestFlags();
      shouldPass = gateFn(flags);
    } catch (e) {
      test(testName, () => {
        throw e;
      });
      return;
    }
    if (shouldPass) {
      test(testName, callback);
    } else {
      test(`[GATED, SHOULD FAIL] ${testName}`, () =>
        expectTestToFail(callback, gatedErrorMessage));
    }
  };
  global._test_gate_focus = (gateFn, testName, callback) => {
    let shouldPass;
    try {
      const flags = getTestFlags();
      shouldPass = gateFn(flags);
    } catch (e) {
      test.only(testName, () => {
        throw e;
      });
      return;
    }
    if (shouldPass) {
      test.only(testName, callback);
    } else {
      test.only(`[GATED, SHOULD FAIL] ${testName}`, () =>
        expectTestToFail(callback, gatedErrorMessage));
    }
  };

  // Dynamic version of @gate pragma
  global.gate = fn => {
    const flags = getTestFlags();
    return fn(flags);
  };
}
