/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import createDebug from 'debug';

// TODO: async-ify this

import fs from 'node:fs';
import path from 'node:path';
import A from 'async';

const debug = createDebug('bom');

import Table from 'cli-table3';
import _ from 'lodash';
import { getCustomJsDependencies } from '../platform/aws-ecs/legacy/bom.ts';

import { parseScript, readScript } from '../util.ts';

import BUILTIN_PLUGINS from './built-in-plugins.ts';

// A file included in a test bundle. noPrefixPosix is attached when
// the manifest is prepared for upload (see legacy create-test.ts).
export interface BomFileEntry {
  orig: string;
  noPrefix: string;
  noPrefixPosix?: string;
}

// The bill of materials for a test: local files to upload and npm
// packages to install on workers.
export interface BomManifest {
  files: BomFileEntry[];
  modules: string[];
  pkgDeps: string[];
}

// Accumulator passed through the BOM waterfall steps.
export interface BomContext {
  opts: {
    scriptData: Record<string, any>;
    absoluteScriptPath: string;
    [key: string]: any;
  };
  localFilePaths: string[];
  npmModules: string[];
  pkgDeps?: string[];
}

export type BomNext = (err: Error | null, context?: BomContext) => void;

// NOTE: Presumes ALL paths are absolute.
async function createBOM(
  absoluteScriptPath: string,
  extraFiles: string[],
  opts: { packageJsonPath?: string; [key: string]: any },
  callback: (err: Error | null, manifest: BomManifest | null) => void
) {
  A.waterfall(
    [
      A.constant(absoluteScriptPath),
      readScript,
      parseScript,
      (scriptData: Record<string, any>, next: BomNext) => {
        return next(null, {
          opts: {
            scriptData,
            absoluteScriptPath
          },
          localFilePaths: [absoluteScriptPath],
          npmModules: []
        });
      },
      getPlugins,
      getCustomEngines,
      getCustomJsDependencies,
      getVariableDataFiles,
      // getFileUploadPluginFiles,
      getExtraFiles
      // expandDirectories
    ],

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

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

      // TODO: Entries in localFilePaths may be directories

      // Handle case with only one entry, where 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);
      }

      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) };
      });

      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 = [];
      }

      return callback(null, {
        files: _.uniqWith(files, _.isEqual),
        modules: _.uniq(context.npmModules).filter(
          (m) =>
            m !== 'artillery' &&
            m !== 'playwright' &&
            !m.startsWith('@playwright/')
        ),
        pkgDeps: context.pkgDeps as string[]
      });
    }
  );
}

function getPlugins(context: BomContext, next: BomNext) {
  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: BomContext, next: BomNext) {
  // TODO: Environment-specific engines (see getPlugins())
  const engineNames = _.uniq(
    Object.keys(_.get(context, 'opts.scriptData.config.engines', {}))
  );
  const enginePackages = engineNames.map((x) => `artillery-engine-${x}`);
  context.npmModules = context.npmModules.concat(enginePackages);

  return next(null, context);
}

function getVariableDataFiles(context: BomContext, next: BomNext) {
  // NOTE: Presuming that the script has been run through the functions
  // that normalize the config.payload definition (presume it's an array).
  // Also assuming that context.opts.scriptData contains both the config and
  // the scenarios section.

  // Iterate over environments

  function resolvePayloadPaths(obj: Record<string, any>) {
    const result: string[] = [];
    if (obj.payload) {
      if (_.isArray(obj.payload)) {
        obj.payload.forEach((payloadSpec) => {
          result.push(
            path.resolve(
              path.dirname(context.opts.absoluteScriptPath),
              payloadSpec.path
            )
          );
        });
      } else if (_.isObject(obj.payload)) {
        // NOTE: isObject returns true for arrays, so this branch must
        // come second.
        result.push(
          path.resolve(
            path.dirname(context.opts.absoluteScriptPath),
            (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 getExtraFiles(context: BomContext, next: BomNext) {
  if (context.opts.scriptData.config?.includeFiles) {
    const absPaths = _.map(
      context.opts.scriptData.config.includeFiles,
      (p: string) => {
        const includePath = path.resolve(
          path.dirname(context.opts.absoluteScriptPath),
          p
        );
        debug('includeFile:', includePath);
        return includePath;
      }
    );
    context.localFilePaths = context.localFilePaths.concat(absPaths);
    return next(null, context);
  } else {
    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: BomManifest) {
  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) {
    t.push([
      m,
      'package',
      manifest.pkgDeps.indexOf(m) === -1 ? 'not in package.json' : ''
    ]);
  }
  artillery.log(t.toString());
  artillery.log();
}

export { createBOM, commonPrefix, prettyPrint };
