// @flow

import { mergeOptions } from "./util";
import * as context from "../index";
import Plugin from "./plugin";
import { getItemDescriptor } from "./item";
import {
  buildPresetChain,
  type ConfigContext,
  type ConfigChain,
  type PresetInstance,
} from "./config-chain";
import type { UnloadedDescriptor } from "./config-descriptors";
import traverse from "@babel/traverse";
import { makeWeakCache, type CacheConfigurator } from "./caching";
import { validate } from "./validation/options";
import { validatePluginObject } from "./validation/plugins";
import makeAPI from "./helpers/config-api";

import loadPrivatePartialConfig from "./partial";

type LoadedDescriptor = {
  value: {},
  options: {},
  dirname: string,
  alias: string,
};

export type { InputOptions } from "./validation/options";

export type ResolvedConfig = {
  options: Object,
  passes: PluginPasses,
};

export type { Plugin };
export type PluginPassList = Array<Plugin>;
export type PluginPasses = Array<PluginPassList>;

// Context not including filename since it is used in places that cannot
// process 'ignore'/'only' and other filename-based logic.
type SimpleContext = {
  envName: string,
};

export default function loadFullConfig(
  inputOpts: mixed,
): ResolvedConfig | null {
  const result = loadPrivatePartialConfig(inputOpts);
  if (!result) {
    return null;
  }
  const { options, context } = result;

  const optionDefaults = {};
  const passes = [[]];
  try {
    const { plugins, presets } = options;

    if (!plugins || !presets) {
      throw new Error("Assertion failure - plugins and presets exist");
    }

    const ignored = (function recurseDescriptors(
      config: {
        plugins: Array<UnloadedDescriptor>,
        presets: Array<UnloadedDescriptor>,
      },
      pass: Array<Plugin>,
    ) {
      const plugins = config.plugins.map(descriptor => {
        return loadPluginDescriptor(descriptor, context);
      });
      const presets = config.presets.map(descriptor => {
        return {
          preset: loadPresetDescriptor(descriptor, context),
          pass: descriptor.ownPass ? [] : pass,
        };
      });

      // resolve presets
      if (presets.length > 0) {
        // The passes are created in the same order as the preset list, but are inserted before any
        // existing additional passes.
        passes.splice(
          1,
          0,
          ...presets.map(o => o.pass).filter(p => p !== pass),
        );

        for (const { preset, pass } of presets) {
          if (!preset) return true;

          const ignored = recurseDescriptors(
            {
              plugins: preset.plugins,
              presets: preset.presets,
            },
            pass,
          );
          if (ignored) return true;

          preset.options.forEach(opts => {
            mergeOptions(optionDefaults, opts);
          });
        }
      }

      // resolve plugins
      if (plugins.length > 0) {
        pass.unshift(...plugins);
      }
    })(
      {
        plugins: plugins.map(item => {
          const desc = getItemDescriptor(item);
          if (!desc) {
            throw new Error("Assertion failure - must be config item");
          }

          return desc;
        }),
        presets: presets.map(item => {
          const desc = getItemDescriptor(item);
          if (!desc) {
            throw new Error("Assertion failure - must be config item");
          }

          return desc;
        }),
      },
      passes[0],
    );

    if (ignored) return null;
  } catch (e) {
    // There are a few case where thrown errors will try to annotate themselves multiple times, so
    // to keep things simple we just bail out if re-wrapping the message.
    if (!/^\[BABEL\]/.test(e.message)) {
      e.message = `[BABEL] ${context.filename || "unknown"}: ${e.message}`;
    }

    throw e;
  }

  const opts: Object = optionDefaults;
  mergeOptions(opts, options);

  opts.plugins = passes[0];
  opts.presets = passes
    .slice(1)
    .filter(plugins => plugins.length > 0)
    .map(plugins => ({ plugins }));
  opts.passPerPreset = opts.presets.length > 0;

  return {
    options: opts,
    passes: passes,
  };
}

/**
 * Load a generic plugin/preset from the given descriptor loaded from the config object.
 */
const loadDescriptor = makeWeakCache(
  (
    { value, options, dirname, alias }: UnloadedDescriptor,
    cache: CacheConfigurator<SimpleContext>,
  ): LoadedDescriptor => {
    // Disabled presets should already have been filtered out
    if (options === false) throw new Error("Assertion failure");

    options = options || {};

    let item = value;
    if (typeof value === "function") {
      const api = {
        ...context,
        ...makeAPI(cache),
      };
      try {
        item = value(api, options, dirname);
      } catch (e) {
        if (alias) {
          e.message += ` (While processing: ${JSON.stringify(alias)})`;
        }
        throw e;
      }
    }

    if (!item || typeof item !== "object") {
      throw new Error("Plugin/Preset did not return an object.");
    }

    if (typeof item.then === "function") {
      throw new Error(
        `You appear to be using an async plugin, ` +
          `which your current version of Babel does not support.` +
          `If you're using a published plugin, ` +
          `you may need to upgrade your @babel/core version.`,
      );
    }

    return { value: item, options, dirname, alias };
  },
);

/**
 * Instantiate a plugin for the given descriptor, returning the plugin/options pair.
 */
function loadPluginDescriptor(
  descriptor: UnloadedDescriptor,
  context: SimpleContext,
): Plugin {
  if (descriptor.value instanceof Plugin) {
    if (descriptor.options) {
      throw new Error(
        "Passed options to an existing Plugin instance will not work.",
      );
    }

    return descriptor.value;
  }

  return instantiatePlugin(loadDescriptor(descriptor, context), context);
}

const instantiatePlugin = makeWeakCache(
  (
    { value, options, dirname, alias }: LoadedDescriptor,
    cache: CacheConfigurator<SimpleContext>,
  ): Plugin => {
    const pluginObj = validatePluginObject(value);

    const plugin = {
      ...pluginObj,
    };
    if (plugin.visitor) {
      plugin.visitor = traverse.explode({
        ...plugin.visitor,
      });
    }

    if (plugin.inherits) {
      const inheritsDescriptor = {
        name: undefined,
        alias: `${alias}$inherits`,
        value: plugin.inherits,
        options,
        dirname,
      };

      // If the inherited plugin changes, reinstantiate this plugin.
      const inherits = cache.invalidate(data =>
        loadPluginDescriptor(inheritsDescriptor, data),
      );

      plugin.pre = chain(inherits.pre, plugin.pre);
      plugin.post = chain(inherits.post, plugin.post);
      plugin.manipulateOptions = chain(
        inherits.manipulateOptions,
        plugin.manipulateOptions,
      );
      plugin.visitor = traverse.visitors.merge([
        inherits.visitor || {},
        plugin.visitor || {},
      ]);
    }

    return new Plugin(plugin, options, alias);
  },
);

/**
 * Generate a config object that will act as the root of a new nested config.
 */
const loadPresetDescriptor = (
  descriptor: UnloadedDescriptor,
  context: ConfigContext,
): ConfigChain | null => {
  return buildPresetChain(
    instantiatePreset(loadDescriptor(descriptor, context)),
    context,
  );
};

const instantiatePreset = makeWeakCache(
  ({ value, dirname, alias }: LoadedDescriptor): PresetInstance => {
    return {
      options: validate("preset", value),
      alias,
      dirname,
    };
  },
);

function chain(a, b) {
  const fns = [a, b].filter(Boolean);
  if (fns.length <= 1) return fns[0];

  return function(...args) {
    for (const fn of fns) {
      fn.apply(this, args);
    }
  };
}
