import semver from "semver";
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import type { InputOptions } from "@babel/core";
import type { EncodedSourceMap } from "@jridgewell/gen-mapping";

const require = createRequire(import.meta.url);

const nodeVersion = semver.clean(process.version.slice(1))!;

function humanize(val: string, noext?: boolean) {
  if (noext) val = path.basename(val, path.extname(val));
  return val.replace(/-/g, " ");
}

interface TestIO {
  loc: string | undefined;
  code: string | undefined;
}

export interface TestFile extends TestIO {
  filename: string | undefined;
}

export interface Test {
  taskDir: string;
  title: string;
  disabled: boolean | string;
  options: TaskOptions;
  optionsDir: string | undefined;
  doNotSetSourceType: boolean;
  externalHelpers: boolean;
  ignoreOutput: boolean;
  stdout: TestIO;
  stderr: TestIO;
  exec: TestFile;
  actual: TestFile;
  expect: TestFile;
  inputSourceMap?: EncodedSourceMap;
  sourceMap: object | undefined;
  sourceMapFile: TestFile;
  sourceMapVisual: TestFile;
  validateSourceMapVisual: boolean;
  validateLogs: boolean;
}

export interface TaskOptions extends InputOptions {
  BABEL_8_BREAKING?: boolean;
  DO_NOT_SET_SOURCE_TYPE?: boolean;
  SKIP_ON_PUBLISH?: boolean;
  externalHelpers?: boolean;
  ignoreOutput?: boolean;
  minNodeVersion?: string;
  minNodeVersionTransform?: string;
  sourceMap?: boolean;
  os?: string | string[];
  validateLogs?: boolean;
  throws?: boolean | string;
  SKIP_babel7plugins_babel8core?: string;
}

type Suite = {
  options: TaskOptions;
  tests: Test[];
  title: string;
  filename: string;
};

function tryResolve(module: string) {
  try {
    return require.resolve(module);
  } catch (_) {
    return null;
  }
}
function assertDirectory(loc: string) {
  if (!fs.statSync(loc).isDirectory()) {
    throw new Error(`Expected ${loc} to be a directory.`);
  }
}

function shouldIgnore(name: string, ignore?: string[]) {
  if (ignore?.includes(name)) {
    return true;
  }

  const ext = path.extname(name);
  const base = path.basename(name, ext);

  return (
    name.startsWith(".") ||
    ext === ".md" ||
    base === "LICENSE" ||
    base === "options" ||
    name === "package.json"
  );
}

const EXTENSIONS = [".js", ".mjs", ".ts", ".tsx", ".cts", ".mts", ".vue"];
const JSON_AND_EXTENSIONS = [".json", ...EXTENSIONS];

function checkFile(
  loc: string,
  allowJSON: boolean,
  matchedLoc: string | undefined,
) {
  const ext = path.extname(loc);
  const extensions = allowJSON ? JSON_AND_EXTENSIONS : EXTENSIONS;

  if (!extensions.includes(ext)) {
    throw new Error(`Unsupported input extension: ${loc}`);
  }
  if (!matchedLoc) {
    return loc;
  } else {
    throw new Error(`Found conflicting file matches: ${matchedLoc},${loc}`);
  }
}

function pushTask(
  taskName: string,
  taskDir: string,
  suite: Suite,
  suiteName: string,
) {
  const taskDirStats = fs.statSync(taskDir);
  let actualLoc,
    expectLoc,
    execLoc,
    execLocAlias,
    taskOptsLoc,
    stdoutLoc,
    stderrLoc,
    sourceMapLoc,
    sourceMapVisualLoc,
    inputSourceMap;

  const taskOpts: TaskOptions = JSON.parse(JSON.stringify(suite.options));
  if (taskDirStats.isDirectory()) {
    const files = fs.readdirSync(taskDir);
    for (const file of files) {
      const loc = path.join(taskDir, file);
      const name = path.basename(file, path.extname(file));

      switch (name) {
        case "input":
          actualLoc = checkFile(loc, false, actualLoc);
          break;
        case "exec":
          execLoc = checkFile(loc, false, execLoc);
          break;
        case "output":
          expectLoc = checkFile(loc, true, expectLoc);
          break;
        case "output.extended":
          expectLoc = checkFile(loc, true, expectLoc);
          break;
        case "options":
          taskOptsLoc = loc;
          Object.assign(taskOpts, require(taskOptsLoc));
          break;
        case "source-map":
          sourceMapLoc = loc;
          break;
        case "source-map-visual":
          sourceMapVisualLoc = loc;
          break;
        case "input-source-map":
          inputSourceMap = JSON.parse(readFile(loc));
          break;
      }
    }
    // If neither input nor exec is present it is not a real testcase
    if (files.length > 0 && !actualLoc && !execLoc) {
      console.warn(`Skipped test folder with invalid layout: ${taskDir}`);
      return;
    }
    actualLoc ??= taskDir + "/input.js";
    execLoc ??= taskDir + "/exec.js";
    expectLoc ??= taskDir + "/output.js";

    stdoutLoc = taskDir + "/stdout.txt";
    stderrLoc = taskDir + "/stderr.txt";
  } else if (taskDirStats.isFile()) {
    const ext = path.extname(taskDir);
    if (!EXTENSIONS.includes(ext)) return;

    execLoc = taskDir;
    execLocAlias = suiteName + "/" + taskName;
  } else {
    console.warn(`Skipped test folder with invalid layout: ${taskDir}`);
    return;
  }

  const shouldIgnore =
    taskOpts.BABEL_8_BREAKING === false ||
    (process.env.IS_PUBLISH ? taskOpts.SKIP_ON_PUBLISH : false);

  if (shouldIgnore) return;

  function buildTestFile(
    loc: string | undefined,
    fileName?: true | string,
  ): TestFile {
    return {
      loc,
      code: readFile(loc),
      filename: !loc
        ? undefined
        : fileName === true
          ? suiteName + "/" + taskName + "/" + path.basename(loc)
          : fileName || undefined,
    };
  }

  const sourceMapFile = buildTestFile(sourceMapLoc, true);
  const sourceMap = sourceMapFile.code
    ? JSON.parse(sourceMapFile.code)
    : undefined;

  const test: Test = {
    taskDir,
    optionsDir: taskOptsLoc ? path.dirname(taskOptsLoc) : undefined,
    title: humanize(taskName, true),
    disabled: taskName.startsWith(".")
      ? true
      : (process.env.TEST_babel7plugins_babel8core &&
          taskOpts.SKIP_babel7plugins_babel8core) ||
        false,
    options: taskOpts,
    doNotSetSourceType: taskOpts.DO_NOT_SET_SOURCE_TYPE ?? false,
    externalHelpers: taskOpts.externalHelpers ?? true,
    validateLogs: taskOpts.validateLogs ?? false,
    ignoreOutput: taskOpts.ignoreOutput ?? false,
    stdout: buildTestFile(stdoutLoc),
    stderr: buildTestFile(stderrLoc),
    exec: buildTestFile(execLoc, execLocAlias),
    actual: buildTestFile(actualLoc, true),
    expect: buildTestFile(expectLoc, true),
    sourceMap,
    sourceMapFile,
    sourceMapVisual: buildTestFile(sourceMapVisualLoc),
    validateSourceMapVisual:
      taskOpts.sourceMaps === true || taskOpts.sourceMaps === "both",
    inputSourceMap,
  };

  if (
    test.exec.code &&
    test.actual.code &&
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
    path.extname(execLoc) !== path.extname(actualLoc!)
  ) {
    throw new Error(
      `Input file extension should match exec file extension: ${execLoc}, ${actualLoc}`,
    );
  }

  delete taskOpts.BABEL_8_BREAKING;
  delete taskOpts.DO_NOT_SET_SOURCE_TYPE;
  delete taskOpts.SKIP_ON_PUBLISH;
  delete taskOpts.SKIP_babel7plugins_babel8core;

  // If there's node requirement, check it before pushing task
  if (taskOpts.minNodeVersion) {
    const minimumVersion = semver.clean(taskOpts.minNodeVersion);

    if (minimumVersion == null) {
      throw new Error(
        `'minNodeVersion' has invalid semver format: ${taskOpts.minNodeVersion}`,
      );
    }

    if (semver.lt(nodeVersion, minimumVersion)) {
      if (test.actual.code) {
        test.exec.code = undefined;
      } else {
        return;
      }
    }

    // Delete to avoid option validation error
    delete taskOpts.minNodeVersion;
  }

  if (taskOpts.minNodeVersionTransform) {
    const minimumVersion = semver.clean(taskOpts.minNodeVersionTransform);

    if (minimumVersion == null) {
      throw new Error(
        `'minNodeVersionTransform' has invalid semver format: ${taskOpts.minNodeVersionTransform}`,
      );
    }

    if (semver.lt(nodeVersion, minimumVersion)) {
      return;
    }

    // Delete to avoid option validation error
    delete taskOpts.minNodeVersionTransform;
  }

  if (taskOpts.os) {
    let os = taskOpts.os;

    if (!Array.isArray(os) && typeof os !== "string") {
      throw new Error(
        `'os' should be either string or string array: ${taskOpts.os}`,
      );
    }

    if (typeof os === "string") {
      os = [os];
    }

    if (!os.includes(process.platform)) {
      return;
    }

    delete taskOpts.os;
  }

  suite.tests.push(test);

  if (taskOpts.throws) {
    if (test.expect.code) {
      throw new Error(
        "Test cannot throw and also return output code: " + expectLoc,
      );
    }
    if (test.sourceMap) {
      throw new Error(
        "Test cannot throw and also return sourcemaps: " + sourceMapLoc,
      );
    }
  }

  if (!test.validateLogs && (test.stdout.code || test.stderr.code)) {
    throw new Error(
      "stdout.txt and stderr.txt are only allowed when the 'validateLogs' option is enabled: " +
        (test.stdout.code ? stdoutLoc : stderrLoc),
    );
  }
  if (test.ignoreOutput) {
    if (test.expect.code) {
      throw new Error(
        "Test cannot ignore its output and also validate it: " + expectLoc,
      );
    }
    if (!test.validateLogs) {
      throw new Error(
        "ignoreOutput can only be used when validateLogs is true: " +
          taskOptsLoc,
      );
    }
  }

  // Delete to avoid option validation error
  delete test.options.validateLogs;
  delete test.options.ignoreOutput;
  delete test.options.externalHelpers;
}

function wrapPackagesArray(
  type: "plugin" | "preset",
  names: (string | [string, object?, string?])[],
  optionsDir: string,
) {
  return names.map(function (val) {
    if (typeof val === "string") val = [val];

    // relative path (outside of monorepo)
    if (val[0].startsWith(".")) {
      if (!optionsDir) {
        throw new Error(
          "Please provide an options.json in test dir when using a " +
            "relative plugin path.",
        );
      }

      val[0] = path.resolve(optionsDir, val[0]);
    } else {
      let name = val[0];
      const match = /^(@babel\/(?:plugin-|preset-)?)(.*)$/.exec(name);
      if (match) {
        name = match[2];
      }

      const monorepoPath = path.join(
        path.dirname(fileURLToPath(import.meta.url)),
        "../../..",
        name.startsWith("codemod") ? "codemods" : "packages",
        `babel-${type}-${name}/lib/index.js`,
      );

      if (fs.existsSync(monorepoPath)) {
        if (match) {
          throw new Error(
            `Remove the "${match[1]}" prefix from "${val[0]}", to load it from the monorepo`,
          );
        }

        val[0] = monorepoPath;
      }
    }

    return val;
  });
}

/**
 * Resolve plugins/presets defined in options.json
 *
 * @export
 * @param {{}} options the imported options.json
 * @param {string} optionsDir the directory where options.json is placed
 * @returns {any} options whose plugins/presets are resolved
 */
export function resolveOptionPluginOrPreset(
  options: any,
  optionsDir: string,
): any {
  if (options.overrides) {
    for (const subOption of options.overrides) {
      resolveOptionPluginOrPreset(subOption, optionsDir);
    }
  }
  if (options.env) {
    for (const envName in options.env) {
      if (!Object.hasOwn(options.env, envName)) continue;
      resolveOptionPluginOrPreset(options.env[envName], optionsDir);
    }
  }
  if (options.plugins) {
    options.plugins = wrapPackagesArray("plugin", options.plugins, optionsDir);
  }
  if (options.presets) {
    options.presets = wrapPackagesArray(
      "preset",
      options.presets,
      optionsDir,
    ).map(function (val) {
      if (val.length > 3) {
        throw new Error(
          "Unexpected extra options " +
            JSON.stringify(val.slice(3)) +
            " passed to preset.",
        );
      }

      return val;
    });
  }
  return options;
}

export default function get(entryLoc: string | URL) {
  if (entryLoc instanceof URL) entryLoc = fileURLToPath(entryLoc);
  const suites: Suite[] = [];

  let rootOpts: TaskOptions = {};
  const rootOptsLoc = tryResolve(entryLoc + "/options");
  if (rootOptsLoc) rootOpts = require(rootOptsLoc);

  for (const suiteName of fs.readdirSync(entryLoc)) {
    if (shouldIgnore(suiteName)) continue;

    const suite: Suite = {
      options: { ...rootOpts },
      tests: [],
      title: humanize(suiteName),
      filename: entryLoc + "/" + suiteName,
    };

    assertDirectory(suite.filename);
    suites.push(suite);

    const suiteOptsLoc = tryResolve(suite.filename + "/options");
    if (suiteOptsLoc) {
      suite.options = resolveOptionPluginOrPreset(
        require(suiteOptsLoc),
        suite.filename,
      );
    }

    for (const taskName of fs.readdirSync(suite.filename)) {
      pushTask(taskName, suite.filename + "/" + taskName, suite, suiteName);
    }
  }

  return suites;
}

export function multiple(entryLoc: string | URL, ignore?: string[]) {
  if (entryLoc instanceof URL) entryLoc = fileURLToPath(entryLoc);
  const categories: Record<string, Suite[]> = {};

  for (const name of fs.readdirSync(entryLoc)) {
    if (shouldIgnore(name, ignore)) continue;

    const loc = path.join(entryLoc, name);
    assertDirectory(loc);

    categories[name] = get(loc);
  }

  return categories;
}

export function readFile(filename: string | undefined) {
  try {
    if (filename === undefined) {
      return "";
    }
    return fs.readFileSync(filename, "utf8").trimRight();
  } catch (e) {
    if (e.code === "ENOENT") {
      return "";
    }
    throw e;
  }
}
