import fs from 'node:fs';
import { createRequire, isBuiltin } from 'node:module';
import path from 'node:path';
import A from 'async';
import createDebug from 'debug';
import esbuild from 'esbuild-wasm';
import _ from 'lodash';
import walkSync from 'walk-sync';
import * as builtinPlugins from './plugins.ts';

const debug = createDebug('bom');
// require.resolve with explicit paths (resolving user test dependencies
// relative to the test script dir, not this file)
const require = createRequire(import.meta.url);

const BUILTIN_PLUGINS = builtinPlugins.getAllPluginNames();
const BUILTIN_ENGINES = builtinPlugins.getOfficialEngines();

import Table from 'cli-table3';
import prepareTestExecutionPlan from '../../../util/prepare-test-execution-plan.ts';
import type { MergedScript, ScriptPrepFlags } from '../../../util.ts';
import {
  parseScript,
  readScript,
  resolveConfigTemplates
} from '../../../util.ts';

interface UnresolvedImport {
  name: string;
  reason: string;
  importer?: string;
}

// Accumulator passed through the legacy BOM waterfall. Richer than the
// create-bom context: carries flags, the scenario path when a separate
// config file is used, and dependency trace results.
interface LegacyBomContext {
  opts: {
    scriptData: Record<string, any>;
    absoluteScriptPath: string;
    flags: Record<string, any> | undefined;
    scenarioPath?: string;
    [key: string]: any;
  };
  localFilePaths: string[];
  npmModules: string[];
  pkgDeps?: string[];
  moduleVersions?: Record<string, string>;
  unresolvedImports?: UnresolvedImport[];
}

type LegacyBomNext = (err: Error | null, context?: LegacyBomContext) => void;

// NOTE: Code below presumes that all paths are absolute

//Tests in Fargate run on ubuntu, which uses posix paths
//This function converts a path to posix path, in case the original path was not posix (e.g. windows runs)
function _convertToPosixPath(p: string) {
  return p.split(path.sep).join(path.posix.sep);
}

// NOTE: absoluteScriptPath here is actually the absolute path to the config file
function createBOM(
  absoluteScriptPath: string,
  extraFiles: string[],
  opts: {
    packageJsonPath?: string;
    flags?: Record<string, any>;
    scenarioPath?: string;
    [key: string]: any;
  },
  callback: (err: Error | null, manifest: Record<string, any> | null) => void
) {
  A.waterfall(
    [
      A.constant(absoluteScriptPath),
      async (scriptPath: string) => {
        let scriptData: Record<string, any>;
        if (scriptPath.toLowerCase().endsWith('.ts')) {
          scriptData = await prepareTestExecutionPlan(
            [scriptPath],
            // NOTE: pre-existing behavior: flags are always set on this
            // path (Fargate/Lambda runs pass CLI flags through).
            opts.flags as ScriptPrepFlags,
            []
          );
          scriptData.config.processor = scriptPath;
        } else {
          const data = await readScript(scriptPath);
          scriptData = await parseScript(data);
        }

        return scriptData;
      },
      (scriptData: Record<string, any>, next: LegacyBomNext) => {
        return next(null, {
          opts: {
            scriptData,
            absoluteScriptPath,
            flags: opts.flags,
            scenarioPath: opts.scenarioPath // Absolute path to the file that holds scenarios
          },
          localFilePaths: [absoluteScriptPath],
          npmModules: []
        });
      },
      applyScriptChanges,
      getPlugins,
      getCustomEngines,
      getCustomJsDependencies,
      getVariableDataFiles,
      getFileUploadPluginFiles,
      getExtraFiles,
      getDotEnv,
      expandDirectories
    ],

    (err: Error | null | undefined, context: LegacyBomContext) => {
      if (err) {
        return callback(err, null);
      }

      context.localFilePaths = context.localFilePaths.concat(extraFiles);

      // TODO: Entries in localFilePaths may be directories

      // How many entries do we have here? If we have only one entry, the string itself
      // will be the common prefix, meaning that when we substring() on it later, we'll
      // get an empty string, ending up with a manifest like:
      // { files:
      //   [ { orig: '/Users/h/tmp/artillery/hello.yaml', noPrefix: '' } ],
      //   modules: [] }
      //
      let prefix = '';
      if (context.localFilePaths.length === 1) {
        prefix = context.localFilePaths[0].substring(
          0,
          context.localFilePaths[0].length -
            path.basename(context.localFilePaths[0]).length
        );
        // This may still be an empty string if the script path is just 'hello.yml':
        prefix = prefix.length === 0 ? context.localFilePaths[0] : prefix;
      } else {
        prefix = commonPrefix(context.localFilePaths);
      }

      prefix = _convertToPosixPath(prefix);
      debug('prefix', prefix);

      //
      // include package.json / package-lock.json / yarn.lock
      //
      let packageDescriptionFiles = ['.npmrc'];
      if (opts.packageJsonPath) {
        packageDescriptionFiles.push(opts.packageJsonPath);
      } else {
        packageDescriptionFiles = packageDescriptionFiles.concat([
          'package.json',
          'package-lock.json',
          'yarn.lock'
        ]);
      }
      const dependencyFiles = packageDescriptionFiles.map((s) =>
        path.join(prefix, s)
      );

      debug(dependencyFiles);

      dependencyFiles.forEach((p) => {
        try {
          if (fs.statSync(p)) {
            context.localFilePaths.push(p);
          }
        } catch (_ignoredErr) {}
      });

      const files = context.localFilePaths.map((p) => {
        return {
          orig: p,
          noPrefix: p.substring(prefix.length, p.length),
          origPosix: _convertToPosixPath(p),
          noPrefixPosix: _convertToPosixPath(p).substring(
            prefix.length,
            p.length
          )
        };
      });

      const pkgPath = _.find(files, (f) => {
        return f.noPrefix === 'package.json';
      });

      if (pkgPath) {
        const pkg = JSON.parse(fs.readFileSync(pkgPath.orig, 'utf8'));
        const pkgDeps = ([] as string[]).concat(
          Object.keys(pkg.dependencies || {}),
          Object.keys(pkg.devDependencies || {})
        );
        context.pkgDeps = pkgDeps;
        context.npmModules = _.uniq(context.npmModules.concat(pkgDeps)).sort();
      } else {
        context.pkgDeps = [];
      }

      const modules = _.uniq(context.npmModules).filter(
        (m) =>
          m !== 'artillery' &&
          m !== 'playwright' &&
          !m.startsWith('@playwright/')
      );

      const moduleVersions = context.moduleVersions || {};
      const unresolvedImports = context.unresolvedImports || [];

      const declaredDeps = new Set(context.pkgDeps);
      const externals: Array<{ name: string; reason: string }> = [];
      for (const m of modules) {
        if (!declaredDeps.has(m)) {
          externals.push({ name: m, reason: 'not-in-package-json' });
        }
      }
      for (const u of unresolvedImports) {
        externals.push({ name: u.name, reason: u.reason });
      }

      return callback(null, {
        files: _.uniqWith(files, _.isEqual),
        modules,
        pkgDeps: context.pkgDeps,
        fullyResolvedConfig: context.opts.scriptData.config,
        moduleVersions,
        externals
      });
    }
  );
}

function applyScriptChanges(context: LegacyBomContext, next: LegacyBomNext) {
  resolveConfigTemplates(
    // NOTE: pre-existing behavior: missing flags crash inside
    // resolveConfigTemplates; all in-repo callers pass them.
    context.opts.scriptData as MergedScript,
    context.opts.flags as ScriptPrepFlags,
    context.opts.absoluteScriptPath,
    context.opts.scenarioPath
  ).then((resolvedConfig) => {
    context.opts.scriptData = resolvedConfig;
    return next(null, context);
  });
}

function getPlugins(context: LegacyBomContext, next: LegacyBomNext) {
  const environmentPlugins = _.reduce(
    _.get(context, 'opts.scriptData.config.environments', {}),
    function getEnvironmentPlugins(
      acc: string[],
      envSpec: Record<string, any>,
      _envName
    ) {
      acc = acc.concat(Object.keys(envSpec.plugins || []));
      return acc;
    },
    []
  );
  const pluginNames = Object.keys(
    _.get(context, 'opts.scriptData.config.plugins', {})
  ).concat(environmentPlugins);

  const pluginPackages = _.uniq(
    pluginNames
      .filter((p) => BUILTIN_PLUGINS.indexOf(p) === -1)
      .map((p) => `artillery-plugin-${p}`)
  );
  debug(pluginPackages);
  context.npmModules = context.npmModules.concat(pluginPackages);

  return next(null, context);
}

function getCustomEngines(context: LegacyBomContext, next: LegacyBomNext) {
  const environmentEngines = _.reduce(
    _.get(context, 'opts.scriptData.config.environments', {}),
    function getEnvironmentEngines(
      acc: string[],
      envSpec: Record<string, any>,
      _envName
    ) {
      acc = acc.concat(Object.keys(envSpec.engines || []));
      return acc;
    },
    []
  );

  const engineNames = Object.keys(
    _.get(context, 'opts.scriptData.config.engines', {})
  ).concat(environmentEngines);

  const enginePackages = _.uniq(
    engineNames
      .filter((p) => BUILTIN_ENGINES.indexOf(p) === -1)
      .map((p) => `artillery-engine-${p}`)
  );

  context.npmModules = context.npmModules.concat(enginePackages);

  return next(null, context);
}

// async waterfall passes ONE arg to async functions and reads the result via
// the returned promise — no `next` callback. Throws propagate as errors.
async function getCustomJsDependencies(context: LegacyBomContext) {
  const scriptPath = context.opts.absoluteScriptPath;
  const isTypeScriptEntry = scriptPath.toLowerCase().endsWith('.ts');
  const resolveRoot = path.dirname(scriptPath);

  const entries: string[] = [];

  // .ts script entry: trace the script itself (handles imports in script body
  // when prepareTestExecutionPlan ingests TypeScript modules).
  if (isTypeScriptEntry) {
    entries.push(scriptPath);
  }

  // Pick the user-declared processor path. If prepareTestExecutionPlan
  // bundled a .ts processor to dist/ and stashed __originalProcessor, use that
  // — we want to trace the original source, not the already-bundled output.
  const originalProcessor = context.opts.scriptData.__originalProcessor;
  const declaredProcessor = context.opts.scriptData.config?.processor;
  let processorEntry: string | null = null;

  if (originalProcessor) {
    processorEntry = originalProcessor;
  } else if (declaredProcessor) {
    const resolved = path.resolve(resolveRoot, declaredProcessor);
    // bom.js applyScriptChanges sets config.processor = scriptPath for .ts
    // entries (preserving older dep-tree behaviour). Avoid double-tracing.
    if (resolved !== scriptPath) {
      processorEntry = resolved;
    }
  }

  if (processorEntry && !entries.includes(processorEntry)) {
    entries.push(processorEntry);
  }

  context.moduleVersions = context.moduleVersions || {};
  context.unresolvedImports = context.unresolvedImports || [];

  if (entries.length === 0) {
    debug('no custom JS dependencies');
    return context;
  }

  const traceResult = await traceDependencies(entries, resolveRoot);

  context.localFilePaths = _.uniq(
    context.localFilePaths.concat(traceResult.localFiles)
  );
  context.npmModules = context.npmModules.concat(traceResult.npmPackages);

  for (const pkg of traceResult.npmPackages) {
    if (context.moduleVersions[pkg]) continue;
    const version = resolvePackageVersion(pkg, resolveRoot);
    if (version) context.moduleVersions[pkg] = version;
  }

  context.unresolvedImports = context.unresolvedImports.concat(
    traceResult.unresolved
  );

  debug('got custom JS dependencies via esbuild');
  return context;
}

// esbuild-wasm's initialize() can only be called once per process. Memoize
// the promise so concurrent or repeat trace calls share a single init.
// This is the only call site for initialize() in the codebase
// (prepare-test-execution-plan.js uses buildSync, which doesn't need init),
// so we don't need to defend against external "already initialized" errors.
let esbuildInitPromise: Promise<void> | null = null;
function ensureEsbuildInitialized(esbuildMod: typeof esbuild) {
  if (!esbuildInitPromise) {
    esbuildInitPromise = esbuildMod.initialize({});
  }
  return esbuildInitPromise;
}

async function traceDependencies(entries: string[], resolveRoot: string) {
  const unresolved: UnresolvedImport[] = [];

  // Marker used to break recursion when we re-enter our own onResolve via
  // build.resolve() below.
  const RESOLVING = { artilleryRecoverResolving: true };

  const recoverPlugin: esbuild.Plugin = {
    name: 'artillery-recover-unresolved',
    setup(build) {
      // Intercept relative imports ('.', '..', './x', '../x') only to
      // recover from resolution failures: delegate to esbuild's own
      // resolver first, and mark the import as external (so the build
      // doesn't fail) only if esbuild itself can't resolve it.
      build.onResolve({ filter: /^\.{1,2}(\/|$)/ }, async (args) => {
        if (args.pluginData === RESOLVING) {
          // Re-entered via build.resolve() — let esbuild's default
          // resolver handle it.
          return null;
        }

        const result = await build.resolve(args.path, {
          resolveDir: args.resolveDir,
          importer: args.importer,
          kind: args.kind,
          pluginData: RESOLVING
        });

        if (result.errors.length === 0) {
          return result;
        }

        unresolved.push({
          name: args.path,
          reason: 'unresolved-relative',
          importer: args.importer
        });
        return { path: args.path, external: true };
      });
    }
  };

  // esbuild-wasm doesn't support plugins via buildSync. Need the async API,
  // which in turn requires a one-time initialize(). prepareTestExecutionPlan
  // uses buildSync directly without initialize — that path stays sync and
  // doesn't conflict with this one (initialize is idempotent within a
  // single process; buildSync works either way).
  await ensureEsbuildInitialized(esbuild);

  const result = await esbuild.build({
    entryPoints: entries,
    bundle: true,
    write: false,
    metafile: true,
    packages: 'external',
    platform: 'node',
    format: 'cjs',
    logLevel: 'silent',
    absWorkingDir: resolveRoot,
    plugins: [recoverPlugin]
  });

  const localFiles = new Set<string>();
  const npmPackages = new Set<string>();

  // metafile is always present: the build above sets metafile: true.
  const metafile = result.metafile as esbuild.Metafile;
  for (const inputPath of Object.keys(metafile.inputs)) {
    const input = metafile.inputs[inputPath];
    const absInputPath = path.isAbsolute(inputPath)
      ? inputPath
      : path.resolve(resolveRoot, inputPath);

    if (!absInputPath.includes(`${path.sep}node_modules${path.sep}`)) {
      localFiles.add(absInputPath);
    }

    for (const imp of input.imports || []) {
      if (!imp.external) continue;
      if (isBuiltin(imp.path)) continue;
      const pkgName = extractPackageName(imp.path);
      if (pkgName) npmPackages.add(pkgName);
    }
  }

  return {
    localFiles: Array.from(localFiles),
    npmPackages: Array.from(npmPackages),
    unresolved
  };
}

function extractPackageName(spec: string): string | null {
  // Relative or absolute specifiers are never npm packages. Without this
  // guard an unresolved '../x' import (kept external by recoverPlugin)
  // becomes a "package" named '..' and ends up npm-installed on remote
  // workers.
  if (spec.startsWith('.') || spec.startsWith('/') || path.isAbsolute(spec)) {
    return null;
  }
  if (spec.startsWith('@')) {
    const parts = spec.split('/');
    return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
  }
  return spec.split('/')[0];
}

function resolvePackageVersion(
  pkgName: string,
  resolveRoot: string
): string | null {
  try {
    const pkgJsonPath = require.resolve(`${pkgName}/package.json`, {
      paths: [resolveRoot]
    });
    const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
    return pkg.version || null;
  } catch (_err) {
    return null;
  }
}

function getVariableDataFiles(context: LegacyBomContext, next: LegacyBomNext) {
  // NOTE: assuming that context.opts.scriptData contains both the config and
  // the scenarios section here.

  // Iterate over environments

  function resolvePayloadPaths(obj: Record<string, any>) {
    const result: string[] = [];
    if (obj.payload) {
      // When using a separate config file, resolve paths relative to the scenario file
      // Otherwise, resolve relative to the config file
      const baseDir = context.opts.scenarioPath
        ? path.dirname(context.opts.scenarioPath)
        : path.dirname(context.opts.absoluteScriptPath);

      if (_.isArray(obj.payload)) {
        obj.payload.forEach((payloadSpec: { path: string }) => {
          result.push(path.resolve(baseDir, payloadSpec.path));
        });
      } else if (_.isObject(obj.payload)) {
        // isObject returns true for arrays, so this branch must come second
        result.push(
          path.resolve(baseDir, (obj.payload as { path: string }).path)
        );
      }
    }
    return result;
  }

  context.localFilePaths = context.localFilePaths.concat(
    resolvePayloadPaths(context.opts.scriptData.config)
  );
  context.opts.scriptData.config.environments =
    context.opts.scriptData.config.environments || {};
  Object.keys(context.opts.scriptData.config.environments).forEach(
    (envName: string) => {
      const envSpec = context.opts.scriptData.config.environments[envName];
      context.localFilePaths = context.localFilePaths.concat(
        resolvePayloadPaths(envSpec)
      );
    }
  );

  return next(null, context);
}

function getFileUploadPluginFiles(
  context: LegacyBomContext,
  next: LegacyBomNext
) {
  if (context.opts.scriptData.config?.plugins?.['http-file-uploads']) {
    // Append filePaths array if it's there:

    if (context.opts.scriptData.config.plugins['http-file-uploads'].filePaths) {
      // When using a separate config file, resolve paths relative to the scenario file
      // Otherwise, resolve relative to the config file
      const baseDir = context.opts.scenarioPath
        ? path.dirname(context.opts.scenarioPath)
        : path.dirname(context.opts.absoluteScriptPath);

      const absPaths = context.opts.scriptData.config.plugins[
        'http-file-uploads'
      ].filePaths.map((p: string) => {
        return path.resolve(baseDir, p);
      });
      context.localFilePaths = context.localFilePaths.concat(absPaths);
    }
    return next(null, context);
  } else {
    return next(null, context);
  }
}

function getExtraFiles(context: LegacyBomContext, next: LegacyBomNext) {
  if (context.opts.scriptData.config?.includeFiles) {
    // When using a separate config file, resolve paths relative to the scenario file
    // Otherwise, resolve relative to the config file
    const baseDir = context.opts.scenarioPath
      ? path.dirname(context.opts.scenarioPath)
      : path.dirname(context.opts.absoluteScriptPath);

    const absPaths = _.map(
      context.opts.scriptData.config.includeFiles,
      (p: string) => {
        const includePath = path.resolve(baseDir, p);
        debug('includeFile:', includePath);
        return includePath;
      }
    );
    context.localFilePaths = context.localFilePaths.concat(absPaths);
    return next(null, context);
  } else {
    return next(null, context);
  }
}

function getDotEnv(context: LegacyBomContext, next: LegacyBomNext) {
  // NOTE: pre-existing behavior: missing flags crash here; all in-repo
  // callers pass them.
  const flags = context.opts.flags as Record<string, any>;
  if (!flags.dotenv || flags.platform === 'aws:ecs') {
    return next(null, context);
  }

  const dotEnvPath = path.resolve(process.cwd(), flags.dotenv);
  try {
    if (fs.statSync(dotEnvPath)) {
      context.localFilePaths.push(dotEnvPath);
    }
  } catch (_ignoredErr) {
    console.log(`WARNING: could not find dotenv file: ${flags.dotenv}`);
  }

  return next(null, context);
}

function expandDirectories(context: LegacyBomContext, next: LegacyBomNext) {
  // This can potentially lead to VERY unexpected behaviour, when used
  // without due care with the file upload plugin (if filePaths is pointed at
  // a directory that contains files OTHER than those to be used with the
  // plugin)
  //
  // TODO: Warn if there are too many files in the directory
  // TODO: Only allow specific filenames or globs, not directories
  debug(context.localFilePaths);
  // FIXME: Don't need to scan twice:
  const dirs = context.localFilePaths.filter((p) => {
    let result = false;
    try {
      result = fs.statSync(p).isDirectory();
    } catch (_fsErr) {}
    return result;
  });
  // Remove directories from the list:
  context.localFilePaths = context.localFilePaths.filter((p) => {
    let result = true;
    try {
      result = !fs.statSync(p).isDirectory();
    } catch (_fsErr) {}
    return result;
  });

  debug('Dirs to expand');
  debug(dirs);
  dirs.forEach((d) => {
    const entries = walkSync.entries(d, { directories: false });
    debug(entries);
    context.localFilePaths = context.localFilePaths.concat(
      entries.map((e) => {
        return path.resolve(d, e.relativePath);
      })
    );
  });

  return next(null, context);
}

function commonPrefix(paths: string[], separator?: string): string {
  if (
    !paths ||
    paths.length === 0 ||
    paths.filter((s) => typeof s !== 'string').length > 0
  ) {
    return '';
  }

  if (paths.includes('/')) {
    return '/';
  }

  const sep = separator ? separator : path.sep;

  const splitPaths = paths.map((p) => p.split(sep));
  const shortestPath = splitPaths.reduce((a, b) => {
    return a.length < b.length ? a : b;
  }, splitPaths[0]);

  let furthestIndex = shortestPath.length;

  for (const p of splitPaths) {
    for (let i = 0; i < furthestIndex; i++) {
      if (p[i] !== shortestPath[i]) {
        furthestIndex = i;
        break;
      }
    }
  }

  const joined = shortestPath.slice(0, furthestIndex).join(sep);

  if (joined.length > 0) {
    // Check if joined path already ends with separator which
    // will happen when input is a root drive on Windows, e.g. "C:\"
    return joined.endsWith(sep) ? joined : joined + sep;
  } else {
    return '';
  }
}

function prettyPrint(manifest: Record<string, any>) {
  artillery.logger({ showTimestamp: true }).log('Test bundle prepared...');
  artillery.log('Test bundle contents:');
  const t = new Table({ head: ['Name', 'Type', 'Notes'] });
  for (const f of manifest.files) {
    t.push([f.noPrefix, 'file']);
  }
  for (const m of manifest.modules) {
    const version = manifest.moduleVersions?.[m];
    const notes: string[] = [];
    if (manifest.pkgDeps.indexOf(m) === -1) notes.push('not in package.json');
    if (version) notes.push(`v${version}`);
    t.push([m, 'package', notes.join(' · ')]);
  }
  artillery.log(t.toString());

  const unresolvedExternals = (manifest.externals || []).filter(
    (e: UnresolvedImport) => e.reason !== 'not-in-package-json'
  );
  if (unresolvedExternals.length > 0) {
    artillery.log('Unresolved imports:');
    const u = new Table({ head: ['Name', 'Reason'] });
    for (const e of unresolvedExternals) {
      u.push([e.name, e.reason]);
    }
    artillery.log(u.toString());
  }
  artillery.log();
}

function enrichPackageJson(
  content: string | Record<string, any>,
  moduleVersions?: Record<string, string>
) {
  const pkg = typeof content === 'string' ? JSON.parse(content) : content;

  const filterBundled = (deps: Record<string, unknown> | undefined) => {
    if (!deps) return deps;
    const filtered: Record<string, unknown> = {};
    for (const [name, version] of Object.entries(deps)) {
      if (
        name !== 'artillery' &&
        name !== 'playwright' &&
        !name.startsWith('@playwright/')
      ) {
        filtered[name] = version;
      }
    }
    return filtered;
  };

  pkg.dependencies = filterBundled(pkg.dependencies) || {};
  pkg.devDependencies = filterBundled(pkg.devDependencies);

  // Add detected modules that aren't already declared. Pin to exact version
  // so the remote runner installs what we observed locally.
  if (moduleVersions) {
    for (const [name, version] of Object.entries(moduleVersions)) {
      if (!version) continue;
      if (
        name === 'artillery' ||
        name === 'playwright' ||
        name.startsWith('@playwright/')
      )
        continue;
      const inDeps = pkg.dependencies && pkg.dependencies[name];
      const inDev = pkg.devDependencies && pkg.devDependencies[name];
      if (!inDeps && !inDev) {
        pkg.dependencies[name] = version;
      }
    }
  }

  return JSON.stringify(pkg, null, 2);
}

// NOTE: getCustomJsDependencies was previously consumed by
// create-bom/create-bom.js without being exported (silently undefined
// under CJS); ESM named imports are validated at load time, so it is
// now exported properly.
export {
  createBOM,
  commonPrefix,
  prettyPrint,
  applyScriptChanges,
  enrichPackageJson,
  getCustomJsDependencies
};
