import * as path from 'path';
import { pathToFileURL } from 'url';
import { existsSync } from 'fs';
import { isAbsoluteUrl } from '../ref-utils';
import { pickDefined, isNotString, isString, isDefined, keysOf } from '../utils';
import { resolveDocument, BaseResolver } from '../resolve';
import { defaultPlugin } from './builtIn';
import {
  getResolveConfig,
  getUniquePlugins,
  isCommonJsPlugin,
  isDeprecatedPluginFormat,
  mergeExtends,
  parsePresetName,
  prefixRules,
  transformConfig,
} from './utils';
import { isBrowser } from '../env';
import { Config } from './config';
import { colorize, logger } from '../logger';
import { asserts, buildAssertCustomFunction } from '../rules/common/assertions/asserts';
import { NormalizedConfigTypes } from '../types/redocly-yaml';

import type {
  StyleguideRawConfig,
  ApiStyleguideRawConfig,
  Plugin,
  RawConfig,
  ResolvedApi,
  ResolvedStyleguideConfig,
  RuleConfig,
  DeprecatedInRawConfig,
  ImportedPlugin,
} from './types';
import type { Assertion, AssertionDefinition, RawAssertion } from '../rules/common/assertions';
import type { Asserts, AssertionFn } from '../rules/common/assertions/asserts';
import type { BundleOptions } from '../bundle';
import type { Document, ResolvedRefMap } from '../resolve';

const DEFAULT_PROJECT_PLUGIN_PATHS = ['@theme/plugin.js', '@theme/plugin.cjs', '@theme/plugin.mjs'];

// Cache instantiated plugins during a single execution
const pluginsCache: Map<string, Plugin> = new Map();

export async function resolveConfigFileAndRefs({
  configPath,
  externalRefResolver = new BaseResolver(),
  base = null,
}: Omit<BundleOptions, 'config'> & { configPath?: string }): Promise<{
  document: Document;
  resolvedRefMap: ResolvedRefMap;
}> {
  if (!configPath) {
    throw new Error('Reference to a config is required.\n');
  }

  const document = await externalRefResolver.resolveDocument(base, configPath, true);

  if (document instanceof Error) {
    throw document;
  }

  const resolvedRefMap = await resolveDocument({
    rootDocument: document,
    rootType: NormalizedConfigTypes.ConfigRoot,
    externalRefResolver,
  });

  return { document, resolvedRefMap };
}

export async function resolveConfig({
  rawConfig,
  configPath,
  externalRefResolver,
}: {
  rawConfig: RawConfig;
  configPath?: string;
  externalRefResolver?: BaseResolver;
}): Promise<Config> {
  if (rawConfig.styleguide?.extends?.some(isNotString)) {
    throw new Error(
      `Error configuration format not detected in extends value must contain strings`
    );
  }

  const resolver = externalRefResolver ?? new BaseResolver(getResolveConfig(rawConfig.resolve));

  const apis = await resolveApis({
    rawConfig,
    configPath,
    resolver,
  });

  const styleguide = await resolveStyleguideConfig({
    styleguideConfig: rawConfig.styleguide,
    configPath,
    resolver,
  });

  return new Config(
    {
      ...rawConfig,
      apis,
      styleguide,
    },
    configPath
  );
}

function getDefaultPluginPath(configDir: string): string | undefined {
  for (const pluginPath of DEFAULT_PROJECT_PLUGIN_PATHS) {
    const absolutePluginPath = path.resolve(configDir, pluginPath);
    if (existsSync(absolutePluginPath)) {
      return pluginPath;
    }
  }
  return;
}

export async function resolvePlugins(
  plugins: (string | Plugin)[] | null,
  configDir: string = ''
): Promise<Plugin[]> {
  if (!plugins) return [];

  // TODO: implement or reuse Resolver approach so it will work in node and browser envs
  const requireFunc = async (plugin: string | Plugin): Promise<Plugin | undefined> => {
    if (isString(plugin)) {
      try {
        const maybeAbsolutePluginPath = path.resolve(configDir, plugin);

        const absolutePluginPath = existsSync(maybeAbsolutePluginPath)
          ? maybeAbsolutePluginPath
          : // For plugins imported from packages specifically
            require.resolve(plugin, {
              paths: [
                // Plugins imported from the node_modules in the project directory
                configDir,
                // Plugins imported from the node_modules in the package install directory (for example, npx cache directory)
                __dirname,
              ],
            });

        if (!pluginsCache.has(absolutePluginPath)) {
          let requiredPlugin: ImportedPlugin | undefined;

          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
          // @ts-ignore
          if (typeof __webpack_require__ === 'function') {
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
            // @ts-ignore
            requiredPlugin = __non_webpack_require__(absolutePluginPath);
          } else {
            // Workaround for dynamic imports being transpiled to require by Typescript: https://github.com/microsoft/TypeScript/issues/43329#issuecomment-811606238
            const _importDynamic = new Function('modulePath', 'return import(modulePath)');
            // you can import both cjs and mjs
            const mod = await _importDynamic(pathToFileURL(absolutePluginPath).href);
            requiredPlugin = mod.default || mod;
          }

          const pluginCreatorOptions = { contentDir: configDir };

          const pluginModule = isDeprecatedPluginFormat(requiredPlugin)
            ? requiredPlugin
            : isCommonJsPlugin(requiredPlugin)
            ? await requiredPlugin(pluginCreatorOptions)
            : await requiredPlugin?.default?.(pluginCreatorOptions);

          if (pluginModule?.id && isDeprecatedPluginFormat(requiredPlugin)) {
            logger.info(`Deprecated plugin format detected: ${pluginModule.id}\n`);
          }

          if (pluginModule) {
            pluginsCache.set(absolutePluginPath, {
              ...pluginModule,
              path: plugin,
              absolutePath: absolutePluginPath,
            });
          }
        }

        return pluginsCache.get(absolutePluginPath);
      } catch (e) {
        throw new Error(`Failed to load plugin "${plugin}": ${e.message}\n\n${e.stack}`);
      }
    }

    return plugin;
  };

  const seenPluginIds = new Map<string, string>();

  /**
   * Include the default plugin automatically if it's not in configuration
   */
  const defaultPluginPath = getDefaultPluginPath(configDir);
  if (defaultPluginPath) {
    plugins.push(defaultPluginPath);
  }

  const resolvedPlugins: Set<string> = new Set();

  const instances = await Promise.all(
    plugins.map(async (p) => {
      if (isString(p)) {
        if (isAbsoluteUrl(p)) {
          throw new Error(colorize.red(`We don't support remote plugins yet.`));
        }
        if (resolvedPlugins.has(p)) {
          return;
        }

        resolvedPlugins.add(p);
      }

      const pluginModule: Plugin | undefined = await requireFunc(p);

      if (!pluginModule) {
        return;
      }

      const id = pluginModule.id;
      if (typeof id !== 'string') {
        throw new Error(
          colorize.red(`Plugin must define \`id\` property in ${colorize.blue(p.toString())}.`)
        );
      }

      if (seenPluginIds.has(id)) {
        const pluginPath = seenPluginIds.get(id)!;
        throw new Error(
          colorize.red(
            `Plugin "id" must be unique. Plugin ${colorize.blue(
              p.toString()
            )} uses id "${colorize.blue(id)}" already seen in ${colorize.blue(pluginPath)}`
          )
        );
      }

      seenPluginIds.set(id, p.toString());

      const plugin: Plugin = {
        id,
        ...(pluginModule.configs ? { configs: pluginModule.configs } : {}),
        ...(pluginModule.typeExtension ? { typeExtension: pluginModule.typeExtension } : {}),
      };

      if (pluginModule.rules) {
        if (
          !pluginModule.rules.oas3 &&
          !pluginModule.rules.oas2 &&
          !pluginModule.rules.async2 &&
          !pluginModule.rules.async3 &&
          !pluginModule.rules.arazzo1 &&
          !pluginModule.rules.overlay1
        ) {
          throw new Error(
            `Plugin rules must have \`oas3\`, \`oas2\`, \`async2\`, \`async3\`, \`arazzo\`, or \`overlay1\` rules "${p}.`
          );
        }
        plugin.rules = {};
        if (pluginModule.rules.oas3) {
          plugin.rules.oas3 = prefixRules(pluginModule.rules.oas3, id);
        }
        if (pluginModule.rules.oas2) {
          plugin.rules.oas2 = prefixRules(pluginModule.rules.oas2, id);
        }
        if (pluginModule.rules.async2) {
          plugin.rules.async2 = prefixRules(pluginModule.rules.async2, id);
        }
        if (pluginModule.rules.async3) {
          plugin.rules.async3 = prefixRules(pluginModule.rules.async3, id);
        }
        if (pluginModule.rules.arazzo1) {
          plugin.rules.arazzo1 = prefixRules(pluginModule.rules.arazzo1, id);
        }
        if (pluginModule.rules.overlay1) {
          plugin.rules.overlay1 = prefixRules(pluginModule.rules.overlay1, id);
        }
      }
      if (pluginModule.preprocessors) {
        if (
          !pluginModule.preprocessors.oas3 &&
          !pluginModule.preprocessors.oas2 &&
          !pluginModule.preprocessors.async2 &&
          !pluginModule.preprocessors.async3 &&
          !pluginModule.preprocessors.arazzo1 &&
          !pluginModule.preprocessors.overlay1
        ) {
          throw new Error(
            `Plugin \`preprocessors\` must have \`oas3\`, \`oas2\`, \`async2\`, \`async3\`, \`arazzo1\`, or \`overlay1\` preprocessors "${p}.`
          );
        }
        plugin.preprocessors = {};
        if (pluginModule.preprocessors.oas3) {
          plugin.preprocessors.oas3 = prefixRules(pluginModule.preprocessors.oas3, id);
        }
        if (pluginModule.preprocessors.oas2) {
          plugin.preprocessors.oas2 = prefixRules(pluginModule.preprocessors.oas2, id);
        }
        if (pluginModule.preprocessors.async2) {
          plugin.preprocessors.async2 = prefixRules(pluginModule.preprocessors.async2, id);
        }
        if (pluginModule.preprocessors.async3) {
          plugin.preprocessors.async3 = prefixRules(pluginModule.preprocessors.async3, id);
        }
        if (pluginModule.preprocessors.arazzo1) {
          plugin.preprocessors.arazzo1 = prefixRules(pluginModule.preprocessors.arazzo1, id);
        }
        if (pluginModule.preprocessors.overlay1) {
          plugin.preprocessors.overlay1 = prefixRules(pluginModule.preprocessors.overlay1, id);
        }
      }

      if (pluginModule.decorators) {
        if (
          !pluginModule.decorators.oas3 &&
          !pluginModule.decorators.oas2 &&
          !pluginModule.decorators.async2 &&
          !pluginModule.decorators.async3 &&
          !pluginModule.decorators.arazzo1 &&
          !pluginModule.decorators.overlay1
        ) {
          throw new Error(
            `Plugin \`decorators\` must have \`oas3\`, \`oas2\`, \`async2\`, \`async3\`, \`arazzo1\`, or \`overlay1\` decorators "${p}.`
          );
        }
        plugin.decorators = {};
        if (pluginModule.decorators.oas3) {
          plugin.decorators.oas3 = prefixRules(pluginModule.decorators.oas3, id);
        }
        if (pluginModule.decorators.oas2) {
          plugin.decorators.oas2 = prefixRules(pluginModule.decorators.oas2, id);
        }
        if (pluginModule.decorators.async2) {
          plugin.decorators.async2 = prefixRules(pluginModule.decorators.async2, id);
        }
        if (pluginModule.decorators.async3) {
          plugin.decorators.async3 = prefixRules(pluginModule.decorators.async3, id);
        }
        if (pluginModule.decorators.arazzo1) {
          plugin.decorators.arazzo1 = prefixRules(pluginModule.decorators.arazzo1, id);
        }
        if (pluginModule.decorators.overlay1) {
          plugin.decorators.overlay1 = prefixRules(pluginModule.decorators.overlay1, id);
        }
      }

      if (pluginModule.assertions) {
        plugin.assertions = pluginModule.assertions;
      }

      return {
        ...pluginModule,
        ...plugin,
      };
    })
  );

  return instances.filter(isDefined);
}

export async function resolveApis({
  rawConfig,
  configPath = '',
  resolver,
}: {
  rawConfig: RawConfig;
  configPath?: string;
  resolver?: BaseResolver;
}): Promise<Record<string, ResolvedApi>> {
  const { apis = {}, styleguide: styleguideConfig = {} } = rawConfig;
  const resolvedApis: Record<string, ResolvedApi> = {};
  for (const [apiName, apiContent] of Object.entries(apis || {})) {
    if (apiContent.styleguide?.extends?.some(isNotString)) {
      throw new Error(
        `Error configuration format not detected in extends value must contain strings`
      );
    }
    const rawStyleguideConfig = getMergedRawStyleguideConfig(
      styleguideConfig,
      apiContent.styleguide
    );
    const resolvedApiConfig = await resolveStyleguideConfig({
      styleguideConfig: rawStyleguideConfig,
      configPath,
      resolver,
    });
    resolvedApis[apiName] = { ...apiContent, styleguide: resolvedApiConfig };
  }
  return resolvedApis;
}

async function resolveAndMergeNestedStyleguideConfig({
  styleguideConfig,
  configPath = '',
  resolver = new BaseResolver(),
  parentConfigPaths = [],
  extendPaths = [],
}: {
  styleguideConfig?: StyleguideRawConfig;
  configPath?: string;
  resolver?: BaseResolver;
  parentConfigPaths?: string[];
  extendPaths?: string[];
}): Promise<ResolvedStyleguideConfig> {
  if (parentConfigPaths.includes(configPath)) {
    throw new Error(`Circular dependency in config file: "${configPath}"`);
  }
  const plugins = isBrowser
    ? // In browser, we don't support plugins from config file yet
      [defaultPlugin]
    : getUniquePlugins(
        await resolvePlugins(
          [...(styleguideConfig?.plugins || []), defaultPlugin],
          path.dirname(configPath)
        )
      );
  const pluginPaths = styleguideConfig?.plugins
    ?.filter(isString)
    .map((p) => path.resolve(path.dirname(configPath), p));

  const resolvedConfigPath = isAbsoluteUrl(configPath)
    ? configPath
    : configPath && path.resolve(configPath);

  const extendConfigs: ResolvedStyleguideConfig[] = await Promise.all(
    styleguideConfig?.extends?.map(async (presetItem) => {
      if (!isAbsoluteUrl(presetItem) && !path.extname(presetItem)) {
        return resolvePreset(presetItem, plugins);
      }
      const pathItem = isAbsoluteUrl(presetItem)
        ? presetItem
        : isAbsoluteUrl(configPath)
        ? new URL(presetItem, configPath).href
        : path.resolve(path.dirname(configPath), presetItem);
      const extendedStyleguideConfig = await loadExtendStyleguideConfig(pathItem, resolver);
      return await resolveAndMergeNestedStyleguideConfig({
        styleguideConfig: extendedStyleguideConfig,
        configPath: pathItem,
        resolver,
        parentConfigPaths: [...parentConfigPaths, resolvedConfigPath],
        extendPaths,
      });
    }) || []
  );

  const { plugins: mergedPlugins = [], ...styleguide } = mergeExtends([
    ...extendConfigs,
    {
      ...styleguideConfig,
      plugins,
      extends: undefined,
      extendPaths: [...parentConfigPaths, resolvedConfigPath],
      pluginPaths,
    },
  ]);

  return {
    ...styleguide,
    extendPaths: styleguide.extendPaths?.filter((path) => path && !isAbsoluteUrl(path)),
    plugins: getUniquePlugins(mergedPlugins),
    recommendedFallback: styleguideConfig?.recommendedFallback,
    doNotResolveExamples: styleguideConfig?.doNotResolveExamples,
  };
}

export async function resolveStyleguideConfig(opts: {
  styleguideConfig?: StyleguideRawConfig;
  configPath?: string;
  resolver?: BaseResolver;
  parentConfigPaths?: string[];
  extendPaths?: string[];
}): Promise<ResolvedStyleguideConfig> {
  const resolvedStyleguideConfig = await resolveAndMergeNestedStyleguideConfig(opts);

  return {
    ...resolvedStyleguideConfig,
    rules:
      resolvedStyleguideConfig.rules && groupStyleguideAssertionRules(resolvedStyleguideConfig),
  };
}

export function resolvePreset(presetName: string, plugins: Plugin[]): ResolvedStyleguideConfig {
  const { pluginId, configName } = parsePresetName(presetName);
  const plugin = plugins.find((p) => p.id === pluginId);
  if (!plugin) {
    throw new Error(
      `Invalid config ${colorize.red(presetName)}: plugin ${pluginId} is not included.`
    );
  }

  const preset = plugin.configs?.[configName];
  if (!preset) {
    throw new Error(
      pluginId
        ? `Invalid config ${colorize.red(
            presetName
          )}: plugin ${pluginId} doesn't export config with name ${configName}.`
        : `Invalid config ${colorize.red(presetName)}: there is no such built-in config.`
    );
  }
  return preset;
}

async function loadExtendStyleguideConfig(
  filePath: string,
  resolver: BaseResolver
): Promise<StyleguideRawConfig> {
  try {
    const { parsed } = (await resolver.resolveDocument(null, filePath)) as Document;
    const rawConfig = transformConfig(parsed as RawConfig & DeprecatedInRawConfig);
    if (!rawConfig.styleguide) {
      throw new Error(`Styleguide configuration format not detected: "${filePath}"`);
    }

    return rawConfig.styleguide;
  } catch (error) {
    throw new Error(`Failed to load "${filePath}": ${error.message}`);
  }
}

function getMergedRawStyleguideConfig(
  rootStyleguideConfig: StyleguideRawConfig,
  apiStyleguideConfig?: ApiStyleguideRawConfig
) {
  const resultLint = {
    ...rootStyleguideConfig,
    ...pickDefined(apiStyleguideConfig),
    rules: { ...rootStyleguideConfig?.rules, ...apiStyleguideConfig?.rules },
    oas2Rules: { ...rootStyleguideConfig?.oas2Rules, ...apiStyleguideConfig?.oas2Rules },
    oas3_0Rules: { ...rootStyleguideConfig?.oas3_0Rules, ...apiStyleguideConfig?.oas3_0Rules },
    oas3_1Rules: { ...rootStyleguideConfig?.oas3_1Rules, ...apiStyleguideConfig?.oas3_1Rules },
    async2Rules: { ...rootStyleguideConfig?.async2Rules, ...apiStyleguideConfig?.async2Rules },
    async3Rules: { ...rootStyleguideConfig?.async3Rules, ...apiStyleguideConfig?.async3Rules },
    arazzo1Rules: { ...rootStyleguideConfig?.arazzo1Rules, ...apiStyleguideConfig?.arazzo1Rules },
    overlay1Rules: {
      ...rootStyleguideConfig?.overlay1Rules,
      ...apiStyleguideConfig?.overlay1Rules,
    },
    preprocessors: {
      ...rootStyleguideConfig?.preprocessors,
      ...apiStyleguideConfig?.preprocessors,
    },
    oas2Preprocessors: {
      ...rootStyleguideConfig?.oas2Preprocessors,
      ...apiStyleguideConfig?.oas2Preprocessors,
    },
    oas3_0Preprocessors: {
      ...rootStyleguideConfig?.oas3_0Preprocessors,
      ...apiStyleguideConfig?.oas3_0Preprocessors,
    },
    oas3_1Preprocessors: {
      ...rootStyleguideConfig?.oas3_1Preprocessors,
      ...apiStyleguideConfig?.oas3_1Preprocessors,
    },
    overlay1Preprocessors: {
      ...rootStyleguideConfig?.overlay1Preprocessors,
      ...apiStyleguideConfig?.overlay1Preprocessors,
    },
    decorators: { ...rootStyleguideConfig?.decorators, ...apiStyleguideConfig?.decorators },
    oas2Decorators: {
      ...rootStyleguideConfig?.oas2Decorators,
      ...apiStyleguideConfig?.oas2Decorators,
    },
    oas3_0Decorators: {
      ...rootStyleguideConfig?.oas3_0Decorators,
      ...apiStyleguideConfig?.oas3_0Decorators,
    },
    oas3_1Decorators: {
      ...rootStyleguideConfig?.oas3_1Decorators,
      ...apiStyleguideConfig?.oas3_1Decorators,
    },
    overlay1Decorators: {
      ...rootStyleguideConfig?.overlay1Decorators,
      ...apiStyleguideConfig?.overlay1Decorators,
    },
    recommendedFallback: apiStyleguideConfig?.extends
      ? false
      : rootStyleguideConfig.recommendedFallback,
  };
  return resultLint;
}

function groupStyleguideAssertionRules({
  rules,
  plugins,
}: ResolvedStyleguideConfig): Record<string, RuleConfig> | undefined {
  if (!rules) {
    return rules;
  }

  // Create a new record to avoid mutating original
  const transformedRules: Record<string, RuleConfig> = {};

  // Collect assertion rules
  const assertions: Assertion[] = [];
  for (const [ruleKey, rule] of Object.entries(rules)) {
    // keep the old assert/ syntax as an alias

    if (
      (ruleKey.startsWith('rule/') || ruleKey.startsWith('assert/')) &&
      typeof rule === 'object' &&
      rule !== null
    ) {
      const assertion = rule as RawAssertion;

      if (plugins) {
        registerCustomAssertions(plugins, assertion);

        // We may have custom assertion inside where block
        for (const context of assertion.where || []) {
          registerCustomAssertions(plugins, context);
        }
      }
      assertions.push({
        ...assertion,
        assertionId: ruleKey,
      });
    } else {
      // If it's not an assertion, keep it as is
      transformedRules[ruleKey] = rule;
    }
  }
  if (assertions.length > 0) {
    transformedRules.assertions = assertions;
  }

  return transformedRules;
}

function registerCustomAssertions(plugins: Plugin[], assertion: AssertionDefinition) {
  for (const field of keysOf(assertion.assertions)) {
    const [pluginId, fn] = field.split('/');

    if (!pluginId || !fn) continue;

    const plugin = plugins.find((plugin) => plugin.id === pluginId);

    if (!plugin) {
      throw Error(colorize.red(`Plugin ${colorize.blue(pluginId)} isn't found.`));
    }

    if (!plugin.assertions || !plugin.assertions[fn]) {
      throw Error(
        `Plugin ${colorize.red(
          pluginId
        )} doesn't export assertions function with name ${colorize.red(fn)}.`
      );
    }

    (asserts as Asserts & { [name: string]: AssertionFn })[field] = buildAssertCustomFunction(
      plugin.assertions[fn]
    );
  }
}
