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

import * as path from 'node:path';
import type {WriteStream} from 'node:tty';
import ansiEscapes from 'ansi-escapes';
import chalk from 'chalk';
import exit from 'exit-x';
import slash from 'slash';
import {TestPathPatterns} from '@jest/pattern';
import type {TestContext} from '@jest/test-result';
import type {Config} from '@jest/types';
import type {IHasteMap as HasteMap} from 'jest-haste-map';
import {formatExecError} from 'jest-message-util';
import {
  isInteractive,
  preRunMessage,
  requireOrImportModule,
  specialChars,
} from 'jest-util';
import {ValidationError} from 'jest-validate';
import {
  type AllowedConfigOptions,
  JestHook,
  KEYS,
  TestWatcher,
  type WatchPlugin,
  type WatchPluginClass,
} from 'jest-watcher';
import FailedTestsCache from './FailedTestsCache';
import SearchSource from './SearchSource';
import getChangedFilesPromise from './getChangedFilesPromise';
import activeFilters from './lib/activeFiltersMessage';
import createContext from './lib/createContext';
import isValidPath from './lib/isValidPath';
import updateGlobalConfig from './lib/updateGlobalConfig';
import {
  filterInteractivePlugins,
  getSortedUsageRows,
} from './lib/watchPluginsHelpers';
import FailedTestsInteractivePlugin from './plugins/FailedTestsInteractive';
import QuitPlugin from './plugins/Quit';
import TestNamePatternPlugin from './plugins/TestNamePattern';
import TestPathPatternPlugin from './plugins/TestPathPattern';
import UpdateSnapshotsPlugin from './plugins/UpdateSnapshots';
import UpdateSnapshotsInteractivePlugin from './plugins/UpdateSnapshotsInteractive';
import runJest from './runJest';
import type {Filter} from './types';

type ReservedInfo = {
  forbiddenOverwriteMessage?: string;
  key?: string;
  overwritable: boolean;
  plugin: WatchPlugin;
};

type WatchPluginKeysMap = Map<string, ReservedInfo>;

const {print: preRunMessagePrint} = preRunMessage;

let hasExitListener = false;

const INTERNAL_PLUGINS = [
  FailedTestsInteractivePlugin,
  TestPathPatternPlugin,
  TestNamePatternPlugin,
  UpdateSnapshotsPlugin,
  UpdateSnapshotsInteractivePlugin,
  QuitPlugin,
];

const RESERVED_KEY_PLUGINS = new Map<
  WatchPluginClass,
  Pick<ReservedInfo, 'forbiddenOverwriteMessage' | 'key'>
>([
  [
    UpdateSnapshotsPlugin,
    {forbiddenOverwriteMessage: 'updating snapshots', key: 'u'},
  ],
  [
    UpdateSnapshotsInteractivePlugin,
    {forbiddenOverwriteMessage: 'updating snapshots interactively', key: 'i'},
  ],
  [QuitPlugin, {forbiddenOverwriteMessage: 'quitting watch mode'}],
]);

export default async function watch(
  initialGlobalConfig: Config.GlobalConfig,
  contexts: Array<TestContext>,
  outputStream: WriteStream,
  hasteMapInstances: Array<HasteMap>,
  stdin: NodeJS.ReadStream = process.stdin,
  hooks: JestHook = new JestHook(),
  filter?: Filter,
): Promise<void> {
  // `globalConfig` will be constantly updated and reassigned as a result of
  // watch mode interactions.
  let globalConfig = initialGlobalConfig;
  let activePlugin: WatchPlugin | null;

  globalConfig = updateGlobalConfig(globalConfig, {
    mode: globalConfig.watch ? 'watch' : 'watchAll',
    passWithNoTests: true,
  });

  const updateConfigAndRun = async ({
    bail,
    changedSince,
    collectCoverage,
    collectCoverageFrom,
    coverageDirectory,
    coverageReporters,
    findRelatedTests,
    mode,
    nonFlagArgs,
    notify,
    notifyMode,
    onlyFailures,
    reporters,
    testNamePattern,
    testPathPatterns,
    updateSnapshot,
    verbose,
  }: AllowedConfigOptions = {}) => {
    const previousUpdateSnapshot = globalConfig.updateSnapshot;
    globalConfig = updateGlobalConfig(globalConfig, {
      bail,
      changedSince,
      collectCoverage,
      collectCoverageFrom,
      coverageDirectory,
      coverageReporters,
      findRelatedTests,
      mode,
      nonFlagArgs,
      notify,
      notifyMode,
      onlyFailures,
      reporters,
      testNamePattern,
      testPathPatterns,
      updateSnapshot,
      verbose,
    });

    startRun(globalConfig);
    globalConfig = updateGlobalConfig(globalConfig, {
      // updateSnapshot is not sticky after a run.
      updateSnapshot:
        previousUpdateSnapshot === 'all' ? 'none' : previousUpdateSnapshot,
    });
  };

  const watchPlugins: Array<WatchPlugin> = INTERNAL_PLUGINS.map(
    InternalPlugin => new InternalPlugin({stdin, stdout: outputStream}),
  );
  for (const plugin of watchPlugins) {
    const hookSubscriber = hooks.getSubscriber();
    if (plugin.apply) {
      plugin.apply(hookSubscriber);
    }
  }

  if (globalConfig.watchPlugins != null) {
    const watchPluginKeys: WatchPluginKeysMap = new Map();
    for (const plugin of watchPlugins) {
      const reservedInfo: Pick<
        ReservedInfo,
        'forbiddenOverwriteMessage' | 'key'
      > =
        RESERVED_KEY_PLUGINS.get(plugin.constructor as WatchPluginClass) || {};
      const key = reservedInfo.key || getPluginKey(plugin, globalConfig);
      if (!key) {
        continue;
      }
      const {forbiddenOverwriteMessage} = reservedInfo;
      watchPluginKeys.set(key, {
        forbiddenOverwriteMessage,
        overwritable: forbiddenOverwriteMessage == null,
        plugin,
      });
    }

    for (const pluginWithConfig of globalConfig.watchPlugins) {
      let plugin: WatchPlugin;
      try {
        const ThirdPartyPlugin = await requireOrImportModule<WatchPluginClass>(
          pluginWithConfig.path,
        );
        plugin = new ThirdPartyPlugin({
          config: pluginWithConfig.config,
          stdin,
          stdout: outputStream,
        });
      } catch (error: any) {
        const errorWithContext = new Error(
          `Failed to initialize watch plugin "${chalk.bold(
            slash(path.relative(process.cwd(), pluginWithConfig.path)),
          )}":\n\n${formatExecError(error, contexts[0].config, {
            noStackTrace: false,
          })}`,
        );
        delete errorWithContext.stack;

        throw errorWithContext;
      }
      checkForConflicts(watchPluginKeys, plugin, globalConfig);

      const hookSubscriber = hooks.getSubscriber();
      if (plugin.apply) {
        plugin.apply(hookSubscriber);
      }
      watchPlugins.push(plugin);
    }
  }

  const failedTestsCache = new FailedTestsCache();
  let searchSources = contexts.map(context => ({
    context,
    searchSource: new SearchSource(context),
  }));
  let isRunning = false;
  let testWatcher: TestWatcher;
  let shouldDisplayWatchUsage = true;
  let isWatchUsageDisplayed = false;

  const emitFileChange = () => {
    if (hooks.isUsed('onFileChange')) {
      const projects = searchSources.map(({context, searchSource}) => ({
        config: context.config,
        testPaths: searchSource
          .findMatchingTests(
            new TestPathPatterns([]).toExecutor({
              rootDir: context.config.rootDir,
            }),
          )
          .tests.map(t => t.path),
      }));
      hooks.getEmitter().onFileChange({projects});
    }
  };

  emitFileChange();

  for (const [index, hasteMapInstance] of hasteMapInstances.entries()) {
    hasteMapInstance.on('change', ({eventsQueue, hasteFS, moduleMap}) => {
      const validPaths = eventsQueue.filter(({filePath}) =>
        isValidPath(globalConfig, filePath),
      );

      if (validPaths.length > 0) {
        const context = (contexts[index] = createContext(
          contexts[index].config,
          {hasteFS, moduleMap},
        ));

        activePlugin = null;

        searchSources = [...searchSources];
        searchSources[index] = {
          context,
          searchSource: new SearchSource(context),
        };
        emitFileChange();
        startRun(globalConfig);
      }
    });
  }

  if (!hasExitListener) {
    hasExitListener = true;
    process.on('exit', () => {
      if (activePlugin) {
        outputStream.write(ansiEscapes.cursorDown());
        outputStream.write(ansiEscapes.eraseDown);
      }
    });
  }

  const startRun = async (globalConfig: Config.GlobalConfig): Promise<void> => {
    if (isRunning) {
      return;
    }

    testWatcher = new TestWatcher({isWatchMode: true});
    if (isInteractive) {
      outputStream.write(specialChars.CLEAR);
    }
    preRunMessagePrint(outputStream);
    isRunning = true;
    const configs = contexts.map(context => context.config);
    const changedFilesPromise = getChangedFilesPromise(globalConfig, configs);

    try {
      await runJest({
        changedFilesPromise,
        contexts,
        failedTestsCache,
        filter,
        globalConfig,
        jestHooks: hooks.getEmitter(),
        onComplete: results => {
          isRunning = false;
          hooks.getEmitter().onTestRunComplete(results);

          // Create a new testWatcher instance so that re-runs won't be blocked.
          // The old instance that was passed to Jest will still be interrupted
          // and prevent test runs from the previous run.
          testWatcher = new TestWatcher({isWatchMode: true});

          // Do not show any Watch Usage related stuff when running in a
          // non-interactive environment
          if (isInteractive) {
            if (shouldDisplayWatchUsage) {
              outputStream.write(usage(globalConfig, watchPlugins));
              shouldDisplayWatchUsage = false; // hide Watch Usage after first run
              isWatchUsageDisplayed = true;
            } else {
              outputStream.write(showToggleUsagePrompt());
              shouldDisplayWatchUsage = false;
              isWatchUsageDisplayed = false;
            }
          } else {
            outputStream.write('\n');
          }
          failedTestsCache.setTestResults(results.testResults);
        },
        outputStream,
        startRun,
        testWatcher,
      });
    } catch (error) {
      // Errors thrown inside `runJest`, e.g. by resolvers, are caught here for
      // continuous watch mode execution. We need to reprint them to the
      // terminal and give just a little bit of extra space so they fit below
      // `preRunMessagePrint` message nicely.
      console.error(
        `\n\n${formatExecError(error as any, contexts[0].config, {
          noStackTrace: false,
        })}`,
      );
    }
  };

  const onKeypress = (key: string) => {
    if (key === KEYS.CONTROL_C || key === KEYS.CONTROL_D) {
      if (typeof stdin.setRawMode === 'function') {
        stdin.setRawMode(false);
      }
      outputStream.write('\n');
      exit(0);
      return;
    }

    if (activePlugin != null && activePlugin.onKey) {
      // if a plugin is activate, Jest should let it handle keystrokes, so ignore
      // them here
      activePlugin.onKey(key);
      return;
    }

    // Abort test run
    const pluginKeys = getSortedUsageRows(watchPlugins, globalConfig).map(
      usage => Number(usage.key).toString(16),
    );
    if (
      isRunning &&
      testWatcher &&
      ['q', KEYS.ENTER, 'a', 'o', 'f', ...pluginKeys].includes(key)
    ) {
      testWatcher.setState({interrupted: true});
      return;
    }

    const matchingWatchPlugin = filterInteractivePlugins(
      watchPlugins,
      globalConfig,
    ).find(plugin => getPluginKey(plugin, globalConfig) === key);

    if (matchingWatchPlugin != null) {
      if (isRunning) {
        testWatcher.setState({interrupted: true});
        return;
      }
      // "activate" the plugin, which has jest ignore keystrokes so the plugin
      // can handle them
      activePlugin = matchingWatchPlugin;
      if (activePlugin.run) {
        activePlugin.run(globalConfig, updateConfigAndRun).then(
          async shouldRerun => {
            activePlugin = null;
            if (shouldRerun) {
              await updateConfigAndRun();
            }
          },
          () => {
            activePlugin = null;
            onCancelPatternPrompt();
          },
        );
      } else {
        activePlugin = null;
      }
    }

    switch (key) {
      case KEYS.ENTER:
        startRun(globalConfig);
        break;
      case 'a':
        globalConfig = updateGlobalConfig(globalConfig, {
          mode: 'watchAll',
          testNamePattern: '',
          testPathPatterns: [],
        });
        startRun(globalConfig);
        break;
      case 'c':
        updateConfigAndRun({
          mode: 'watch',
          testNamePattern: '',
          testPathPatterns: [],
        });
        break;
      case 'f':
        globalConfig = updateGlobalConfig(globalConfig, {
          onlyFailures: !globalConfig.onlyFailures,
        });
        startRun(globalConfig);
        break;
      case 'o':
        globalConfig = updateGlobalConfig(globalConfig, {
          mode: 'watch',
          testNamePattern: '',
          testPathPatterns: [],
        });
        startRun(globalConfig);
        break;
      case '?':
        break;
      case 'w':
        if (!shouldDisplayWatchUsage && !isWatchUsageDisplayed) {
          outputStream.write(ansiEscapes.cursorUp());
          outputStream.write(ansiEscapes.eraseDown);
          outputStream.write(usage(globalConfig, watchPlugins));
          isWatchUsageDisplayed = true;
          shouldDisplayWatchUsage = false;
        }
        break;
    }
  };

  const onCancelPatternPrompt = () => {
    outputStream.write(ansiEscapes.cursorHide);
    outputStream.write(specialChars.CLEAR);
    outputStream.write(usage(globalConfig, watchPlugins));
    outputStream.write(ansiEscapes.cursorShow);
  };

  if (typeof stdin.setRawMode === 'function') {
    stdin.setRawMode(true);
    stdin.resume();
    stdin.setEncoding('utf8');
    stdin.on('data', onKeypress);
  }

  startRun(globalConfig);
}

const checkForConflicts = (
  watchPluginKeys: WatchPluginKeysMap,
  plugin: WatchPlugin,
  globalConfig: Config.GlobalConfig,
) => {
  const key = getPluginKey(plugin, globalConfig);
  if (!key) {
    return;
  }

  const conflictor = watchPluginKeys.get(key);
  if (!conflictor || conflictor.overwritable) {
    watchPluginKeys.set(key, {
      overwritable: false,
      plugin,
    });
    return;
  }

  let error;
  if (conflictor.forbiddenOverwriteMessage) {
    error = `
  Watch plugin ${chalk.bold.red(
    getPluginIdentifier(plugin),
  )} attempted to register key ${chalk.bold.red(`<${key}>`)},
  that is reserved internally for ${chalk.bold.red(
    conflictor.forbiddenOverwriteMessage,
  )}.
  Please change the configuration key for this plugin.`.trim();
  } else {
    const plugins = [conflictor.plugin, plugin]
      .map(p => chalk.bold.red(getPluginIdentifier(p)))
      .join(' and ');
    error = `
  Watch plugins ${plugins} both attempted to register key ${chalk.bold.red(
    `<${key}>`,
  )}.
  Please change the key configuration for one of the conflicting plugins to avoid overlap.`.trim();
  }

  throw new ValidationError('Watch plugin configuration error', error);
};

const getPluginIdentifier = (plugin: WatchPlugin) =>
  // This breaks as `displayName` is not defined as a static, but since
  // WatchPlugin is an interface, and it is my understanding interface
  // static fields are not definable anymore, no idea how to circumvent
  // this :-(
  // @ts-expect-error: leave `displayName` be.
  plugin.constructor.displayName || plugin.constructor.name;

const getPluginKey = (
  plugin: WatchPlugin,
  globalConfig: Config.GlobalConfig,
) => {
  if (typeof plugin.getUsageInfo === 'function') {
    return (plugin.getUsageInfo(globalConfig) || {key: null}).key;
  }

  return null;
};

const usage = (
  globalConfig: Config.GlobalConfig,
  watchPlugins: Array<WatchPlugin>,
  delimiter = '\n',
) => {
  const testPathPatterns = globalConfig.testPathPatterns;
  const messages = [
    activeFilters(globalConfig),

    testPathPatterns.isSet() || globalConfig.testNamePattern
      ? `${chalk.dim(' \u203A Press ')}c${chalk.dim(' to clear filters.')}`
      : null,
    `\n${chalk.bold('Watch Usage')}`,

    globalConfig.watch
      ? `${chalk.dim(' \u203A Press ')}a${chalk.dim(' to run all tests.')}`
      : null,

    globalConfig.onlyFailures
      ? `${chalk.dim(' \u203A Press ')}f${chalk.dim(
          ' to quit "only failed tests" mode.',
        )}`
      : `${chalk.dim(' \u203A Press ')}f${chalk.dim(
          ' to run only failed tests.',
        )}`,

    (globalConfig.watchAll ||
      testPathPatterns.isSet() ||
      globalConfig.testNamePattern) &&
    !globalConfig.noSCM
      ? `${chalk.dim(' \u203A Press ')}o${chalk.dim(
          ' to only run tests related to changed files.',
        )}`
      : null,

    ...getSortedUsageRows(watchPlugins, globalConfig).map(
      plugin =>
        `${chalk.dim(' \u203A Press')} ${plugin.key} ${chalk.dim(
          `to ${plugin.prompt}.`,
        )}`,
    ),

    `${chalk.dim(' \u203A Press ')}Enter${chalk.dim(
      ' to trigger a test run.',
    )}`,
  ];

  return `${messages.filter(message => !!message).join(delimiter)}\n`;
};

const showToggleUsagePrompt = () =>
  '\n' +
  `${chalk.bold('Watch Usage: ')}${chalk.dim('Press ')}w${chalk.dim(
    ' to show more.',
  )}`;
