"use strict";

const debug = require("debug")("mocha:cli:watch");
const path = require("node:path");
const chokidar = require("chokidar");
const glob = require("glob");
const isPathInside = require("is-path-inside");
const { minimatch } = require("minimatch");
const Context = require("../context.js").Context;
const collectFiles = require("./collect-files.cjs");
const { logSymbols } = require("../utils.cjs");

/**
 * @typedef {import('chokidar').FSWatcher} FSWatcher
 * @typedef {import('glob').Glob['patterns'][number]} Pattern
 * The `Pattern` class is not exported by the `glob` package.
 * Ref [link](../../node_modules/glob/dist/commonjs/pattern.d.ts).
 * @typedef {import('../mocha.cjs')} Mocha
 * @typedef {import('../types.d.ts').BeforeWatchRun} BeforeWatchRun
 * @typedef {import('../types.d.ts').FileCollectionOptions} FileCollectionOptions
 * @typedef {import('../types.d.ts').Rerunner} Rerunner
 * @typedef {import('../types.d.ts').PathPattern} PathPattern
 * @typedef {import('../types.d.ts').PathFilter} PathFilter
 * @typedef {import('../types.d.ts').PathMatcher} PathMatcher
 */

/**
 * Exports the `watchRun` function that runs mocha in "watch" mode.
 * @see module:lib/cli/run-helpers
 * @module
 * @private
 */

/**
 * Run Mocha in parallel "watch" mode
 * @param {Mocha} mocha - Mocha instance
 * @param {Object} opts - Options
 * @param {string[]} [opts.watchFiles] - List of paths and patterns to
 *   watch. If not provided all files with an extension included in
 *   `fileCollectionParams.extension` are watched. See first argument of
 *   `chokidar.watch`.
 * @param {string[]} opts.watchIgnore - List of paths and patterns to
 *   exclude from watching. See `ignored` option of `chokidar`.
 * @param {FileCollectionOptions} fileCollectParams - Parameters that control test
 * @private
 */
exports.watchParallelRun = (
  mocha,
  { watchFiles, watchIgnore, clearScreen },
  fileCollectParams,
) => {
  debug("creating parallel watcher");

  return createWatcher(mocha, {
    watchFiles,
    watchIgnore,
    clearScreen,
    beforeRun({ mocha }) {
      // I don't know why we're cloning the root suite.
      const rootSuite = mocha.suite.clone();

      // ensure we aren't leaking event listeners
      mocha.dispose();

      // this `require` is needed because the require cache has been cleared.  the dynamic
      // exports set via the below call to `mocha.ui()` won't work properly if a
      // test depends on this module.
      const Mocha = require("../mocha.cjs");

      // ... and now that we've gotten a new module, we need to use it again due
      // to `mocha.ui()` call
      const newMocha = new Mocha(mocha.options);
      // don't know why this is needed
      newMocha.suite = rootSuite;
      // nor this
      newMocha.suite.ctx = new Context();

      // reset the list of files
      newMocha.files = collectFiles(fileCollectParams).files;

      // because we've swapped out the root suite (see the `run` inner function
      // in `createRerunner`), we need to call `mocha.ui()` again to set up the context/globals.
      newMocha.ui(newMocha.options.ui);

      // we need to call `newMocha.rootHooks` to set up rootHooks for the new
      // suite
      newMocha.rootHooks(newMocha.options.rootHooks);

      // in parallel mode, the main Mocha process doesn't actually load the
      // files. this flag prevents `mocha.run()` from autoloading.
      newMocha.lazyLoadFiles(true);
      return newMocha;
    },
    fileCollectParams,
  });
};

/**
 * Run Mocha in "watch" mode
 * @param {Mocha} mocha - Mocha instance
 * @param {Object} opts - Options
 * @param {string[]} [opts.watchFiles] - List of paths and patterns to
 *   watch. If not provided all files with an extension included in
 *   `fileCollectionParams.extension` are watched. See first argument of
 *   `chokidar.watch`.
 * @param {string[]} opts.watchIgnore - List of paths and patterns to
 *   exclude from watching. See `ignored` option of `chokidar`.
 * @param {FileCollectionOptions} fileCollectParams - Parameters that control test
 *   file collection. See `lib/cli/collect-files.cjs`.
 * @private
 */
exports.watchRun = (
  mocha,
  { watchFiles, watchIgnore, clearScreen },
  fileCollectParams,
) => {
  debug("creating serial watcher");

  return createWatcher(mocha, {
    watchFiles,
    watchIgnore,
    clearScreen,
    beforeRun({ mocha }) {
      mocha.unloadFiles();

      // I don't know why we're cloning the root suite.
      const rootSuite = mocha.suite.clone();

      // ensure we aren't leaking event listeners
      mocha.dispose();

      // this `require` is needed because the require cache has been cleared.  the dynamic
      // exports set via the below call to `mocha.ui()` won't work properly if a
      // test depends on this module.
      const Mocha = require("../mocha.cjs");

      // ... and now that we've gotten a new module, we need to use it again due
      // to `mocha.ui()` call
      const newMocha = new Mocha(mocha.options);
      // don't know why this is needed
      newMocha.suite = rootSuite;
      // nor this
      newMocha.suite.ctx = new Context();

      // reset the list of files
      newMocha.files = collectFiles(fileCollectParams).files;

      // because we've swapped out the root suite (see the `run` inner function
      // in `createRerunner`), we need to call `mocha.ui()` again to set up the context/globals.
      newMocha.ui(newMocha.options.ui);

      // we need to call `newMocha.rootHooks` to set up rootHooks for the new
      // suite
      newMocha.rootHooks(newMocha.options.rootHooks);

      return newMocha;
    },
    fileCollectParams,
  });
};

/**
 * Extracts out paths without the glob part, the directory paths,
 * and the paths for matching from the provided glob paths.
 * @param {string[]} globPaths The list of glob paths to create a filter for.
 * @param {string} basePath The path where mocha is run (e.g., current working directory).
 * @returns {PathFilter} Object to filter paths.
 * @ignore
 * @private
 */
function createPathFilter(globPaths, basePath) {
  debug("creating path filter from glob paths: %s", globPaths);

  /**
   * The resulting object to filter paths.
   * @type {PathFilter}
   */
  const res = {
    dir: { paths: new Set(), globs: new Set() },
    match: { paths: new Set(), globs: new Set() },
  };

  // for checking if a path ends with `/**/*`
  const globEnd = path.join(path.sep, "**", "*");

  /**
   * The current glob pattern to check.
   * @type {Pattern[]}
   */
  const patterns = globPaths.flatMap((globPath) => {
    return new glob.Glob(globPath, {
      dot: true,
      magicalBraces: true,
      windowsPathsNoEscape: true,
    }).patterns;
  }, []);

  // each pattern will have its own path because of the `magicalBraces` option
  for (const pattern of patterns) {
    debug("processing glob pattern: %s", pattern.globString());

    /**
     * Path segments before the glob pattern.
     * @type {string[]}
     */
    const segments = [];

    /**
     * The current glob pattern to check.
     * @type {Pattern | null}
     */
    let currentPattern = pattern;
    let isGlob = false;

    do {
      // save string patterns until a non-string (glob or regexp) is matched
      const entry = currentPattern.pattern();
      const isString = typeof entry === "string";
      debug(
        "found %s pattern: %s",
        isString ? "string" : "glob or regexp",
        entry,
      );
      if (!isString) {
        // if the entry is a glob
        isGlob = true;
        break;
      }

      segments.push(entry);

      // go to next pattern
    } while ((currentPattern = currentPattern.rest()));
    if (!isGlob) {
      debug("all subpatterns of %j processed", pattern.globString());
    }

    // match `cleanPath` (path without the glob part) and its subdirectories
    const cleanPath = path.resolve(basePath, ...segments);
    debug("clean path: %s", cleanPath);
    res.dir.paths.add(cleanPath);
    res.dir.globs.add(path.resolve(cleanPath, "**", "*"));

    // match `absPath` and all of its contents
    const absPath = path.resolve(basePath, pattern.globString());
    debug("absolute path: %s", absPath);
    (isGlob ? res.match.globs : res.match.paths).add(absPath);

    // always include `/**/*` to the full pattern for matching
    // since it's possible for the last path segment to be a directory
    if (!absPath.endsWith(globEnd)) {
      res.match.globs.add(path.resolve(absPath, "**", "*"));
    }
  }

  debug("returning path filter: %o", res);
  return res;
}

/**
 * Checks if the provided path matches with the path pattern.
 * @param {string} filePath The path to match.
 * @param {PathPattern} pattern The path pattern for matching.
 * @param {boolean} [matchParent] Treats the provided path as a match if it's a valid parent directory from the list of paths.
 * @returns {boolean} Determines if the provided path matches the pattern.
 * @ignore
 * @private
 */
function matchPattern(filePath, pattern, matchParent) {
  if (pattern.paths.has(filePath)) {
    return true;
  }

  if (matchParent) {
    for (const childPath of pattern.paths) {
      if (isPathInside(childPath, filePath)) {
        return true;
      }
    }
  }

  // loop through the set of glob paths instead of converting it into an array
  for (const globPath of pattern.globs) {
    if (
      minimatch(filePath, globPath, { dot: true, windowsPathsNoEscape: true })
    ) {
      return true;
    }
  }

  return false;
}

/**
 * Creates an object for matching allowed or ignored file paths.
 * @param {PathFilter} allowed The filter for allowed paths.
 * @param {PathFilter} ignored The filter for ignored paths.
 * @param {string} basePath The path where mocha is run (e.g., current working directory).
 * @returns {PathMatcher} The object for matching paths.
 * @ignore
 * @private
 */
function createPathMatcher(allowed, ignored, basePath) {
  debug(
    "creating path matcher from allowed: %o, ignored: %o",
    allowed,
    ignored,
  );

  /**
   * Cache of known file paths processed by `matcher.allow()`.
   * @type {Map<string, boolean>}
   */
  const allowCache = new Map();

  /**
   * Cache of known file paths processed by `matcher.ignore()`.
   * @type {Map<string, boolean>}
   */
  const ignoreCache = new Map();

  const MAX_CACHE_SIZE = 10000;

  /**
   * Performs a `map.set()` but will delete the first key
   * for new key-value pairs whenever the limit is reached.
   * @param {Map<string, boolean>} map The map to use.
   * @param {string} key The key to use.
   * @param {boolean} value The value to set.
   */
  function cache(map, key, value) {
    // only delete the first key if the key doesn't exist in the map
    if (map.size >= MAX_CACHE_SIZE && !map.has(key)) {
      map.delete(map.keys().next().value);
    }
    map.set(key, value);
  }

  /**
   * @type {PathMatcher}
   */
  const matcher = {
    allow(filePath) {
      let allow = allowCache.get(filePath);
      if (allow !== undefined) {
        return allow;
      }

      allow = matchPattern(filePath, allowed.match);
      cache(allowCache, filePath, allow);
      return allow;
    },

    ignore(filePath, stats) {
      // Chokidar calls the ignore match function twice:
      // once without `stats` and again with `stats`
      // see `ignored` under https://github.com/paulmillr/chokidar?tab=readme-ov-file#path-filtering
      // note that the second call can also have no `stats` if the `filePath` does not exist
      // in which case, allow the nonexistent path since it may be created later
      if (!stats) {
        return false;
      }

      // resolve to ensure correct absolute path since, for some reason,
      // Chokidar paths for the ignore match function use slashes `/` even for Windows
      filePath = path.resolve(basePath, filePath);

      let ignore = ignoreCache.get(filePath);
      if (ignore !== undefined) {
        return ignore;
      }

      // `filePath` ignore conditions:
      // - check if it's ignored from the `ignored` path patterns
      // - otherwise, check if it's not ignored via `matcher.allow()` to also cache the result
      // - if no match was found and `filePath` is a directory,
      // check from the allowed directory paths if it's a valid
      // parent directory or if it matches any of the allowed patterns
      // since ignoring directories will have Chokidar ignore their contents
      // which we may need to watch changes for
      ignore =
        matchPattern(filePath, ignored.match) ||
        (!matcher.allow(filePath) &&
          (!stats.isDirectory() || !matchPattern(filePath, allowed.dir, true)));

      cache(ignoreCache, filePath, ignore);
      return ignore;
    },
  };

  return matcher;
}

/**
 * Bootstraps a Chokidar watcher. Handles keyboard input & signals
 * @param {Mocha} mocha - Mocha instance
 * @param {Object} opts
 * @param {BeforeWatchRun} [opts.beforeRun] - Function to call before
 * `mocha.run()`
 * @param {string[]} [opts.watchFiles] - List of paths and patterns to watch. If
 *   not provided all files with an extension included in
 *   `fileCollectionParams.extension` are watched. See first argument of
 *   `chokidar.watch`.
 * @param {string[]} [opts.watchIgnore] - List of paths and patterns to exclude
 *   from watching. See `ignored` option of `chokidar`.
 * @param {boolean} [opts.clearScreen] - Clear the terminal before each run.
 * @param {FileCollectionOptions} opts.fileCollectParams - List of extensions to watch if `opts.watchFiles` is not given.
 * @returns {FSWatcher}
 * @ignore
 * @private
 */
const createWatcher = (
  mocha,
  { watchFiles, watchIgnore, clearScreen, beforeRun, fileCollectParams },
) => {
  if (!watchFiles) {
    watchFiles = fileCollectParams.extension.map((ext) => `**/*.${ext}`);
  }

  debug("watching files: %s", watchFiles);
  debug("ignoring files matching: %s", watchIgnore);
  let globalFixtureContext;

  // we handle global fixtures manually
  mocha.enableGlobalSetup(false).enableGlobalTeardown(false);

  // glob file paths are no longer supported by Chokidar since v4
  // first, strip the glob paths from `watchFiles` for Chokidar to watch
  // then, create path patterns from `watchFiles` and `watchIgnore`
  // to determine if the files should be allowed or ignored
  // by the Chokidar `ignored` match function

  const basePath = process.cwd();
  const allowed = createPathFilter(watchFiles, basePath);
  const ignored = createPathFilter(watchIgnore, basePath);
  const matcher = createPathMatcher(allowed, ignored, basePath);

  // Chokidar has to watch the directory paths in case new files are created
  const watcher = chokidar.watch(Array.from(allowed.dir.paths), {
    ignoreInitial: true,
    ignored: matcher.ignore,
  });

  const rerunner = createRerunner(mocha, watcher, {
    beforeRun,
    clearScreen,
  });

  watcher.on("ready", async () => {
    debug("watcher ready");
    if (!globalFixtureContext) {
      debug("triggering global setup");
      globalFixtureContext = await mocha.runGlobalSetup();
    }
    rerunner.run();
  });

  watcher.on("all", (_event, filePath) => {
    // only allow file paths that match the allowed patterns
    if (matcher.allow(filePath)) {
      rerunner.scheduleRun();
    }
  });

  hideCursor();
  process.on("exit", () => {
    showCursor();
  });

  // this is for testing.
  // win32 cannot gracefully shutdown via a signal from a parent
  // process; a `SIGINT` from a parent will cause the process
  // to immediately exit.  during normal course of operation, a user
  // will type Ctrl-C and the listener will be invoked, but this
  // is not possible in automated testing.
  // there may be another way to solve this, but it too will be a hack.
  // for our watch tests on win32 we must _fork_ mocha with an IPC channel
  if (process.connected) {
    process.on("message", (msg) => {
      if (msg === "SIGINT") {
        process.emit("SIGINT");
      }
    });
  }

  let exiting = false;
  process.on("SIGINT", async () => {
    showCursor();
    console.error(`${logSymbols.warning} [mocha] cleaning up, please wait...`);
    if (!exiting) {
      exiting = true;
      if (mocha.hasGlobalTeardownFixtures()) {
        debug("running global teardown");
        try {
          await mocha.runGlobalTeardown(globalFixtureContext);
        } catch (err) {
          console.error(err);
        }
      }
      process.exit(130);
    }
  });

  // Keyboard shortcut for restarting when "rs\n" is typed (ala Nodemon)
  process.stdin.resume();
  process.stdin.setEncoding("utf8");
  process.stdin.on("data", (data) => {
    const str = data.toString().trim().toLowerCase();
    if (str === "rs") rerunner.scheduleRun();
  });

  return watcher;
};

/**
 * Create an object that allows you to rerun tests on the mocha instance.
 *
 * @param {Mocha} mocha - Mocha instance
 * @param {FSWatcher} watcher - Chokidar `FSWatcher` instance
 * @param {Object} [opts] - Options!
 * @param {BeforeWatchRun} [opts.beforeRun] - Function to call before `mocha.run()`
 * @param {boolean} [opts.clearScreen] - Clear the terminal before each run
 * @returns {Rerunner}
 * @ignore
 * @private
 */
const createRerunner = (mocha, watcher, { beforeRun, clearScreen } = {}) => {
  // Set to a `Runner` when mocha is running. Set to `null` when mocha is not
  // running.
  let runner = null;

  // true if a file has changed during a test run
  let rerunScheduled = false;

  const run = () => {
    try {
      if (clearScreen) {
        clearTerminal();
      }
      mocha = beforeRun ? beforeRun({ mocha, watcher }) || mocha : mocha;
      runner = mocha.run(() => {
        debug("finished watch run");
        runner = null;
        blastCache(watcher);
        if (rerunScheduled) {
          rerun();
        } else {
          console.error(`${logSymbols.info} [mocha] waiting for changes...`);
        }
      });
    } catch (err) {
      console.error(err.stack);
    }
  };

  const scheduleRun = () => {
    if (rerunScheduled) {
      return;
    }

    rerunScheduled = true;
    if (runner) {
      runner.abort();
    } else {
      rerun();
    }
  };

  const rerun = () => {
    rerunScheduled = false;
    eraseLine();
    run();
  };

  return {
    scheduleRun,
    run,
  };
};

/**
 * Return the list of absolute paths watched by a Chokidar watcher.
 *
 * @param watcher - Instance of a Chokidar watcher
 * @return {string[]} - List of absolute paths
 * @ignore
 * @private
 */
const getWatchedFiles = (watcher) => {
  const watchedDirs = watcher.getWatched();
  return Object.keys(watchedDirs).reduce(
    (acc, dir) => [
      ...acc,
      ...watchedDirs[dir].map((file) => path.join(dir, file)),
    ],
    [],
  );
};

/**
 * Hide the cursor.
 * @ignore
 * @private
 */
const hideCursor = () => {
  process.stdout.write("\u001b[?25l");
};

/**
 * Show the cursor.
 * @ignore
 * @private
 */
const showCursor = () => {
  process.stdout.write("\u001b[?25h");
};

/**
 * Erases the line on stdout
 * @private
 */
const eraseLine = () => {
  process.stdout.write("\u001b[2K");
};

/**
 * Clears the terminal and moves the cursor to the top-left corner.
 * @private
 */
const clearTerminal = () => {
  // "\u001b[2J" clears the screen. "\u001b[H" moves the cursor to the home position
  process.stdout.write("\u001b[2J\u001b[H");
};

/**
 * Blast all of the watched files out of `require.cache`
 * @param {FSWatcher} watcher - Chokidar FSWatcher
 * @ignore
 * @private
 */
const blastCache = (watcher) => {
  const files = getWatchedFiles(watcher);
  files.forEach((file) => {
    delete require.cache[file];
  });
  debug("deleted %d file(s) from the require cache", files.length);
};
