/* eslint-disable @typescript-eslint/prefer-interface */
import * as path from 'path';
import * as url from 'url';
import { minVersion } from 'semver';
import parseDependencies from 'searequire';
import coreModules from './core_module';
import { IOptions, taskCtrl } from './index';

/**
 * Loading result of module tree
 */
export interface LoadedModule {
  id: string; // The id the of module, e.g. /a/b.js, /b.js, lodash/lib/index.js
  alias: string[]; // shortcut require path of module, e.g. fold -> fold/index.js, antd -> antd/lib/index.js
  url: string; // final url
  code: string;
  requires: { [path: string]: string }; // require path -> module url
}

export type LoadedModules = { [key: string]: LoadedModule };

export async function loadEntry(
  entry: string[],
  opts: IOptions,
  taskId: number
) {
  const allLoaded: { [id: string]: LoadedModule } = {};
  const loadedNpmPkgs: { [name: string]: any } = {}; // all loaded package.json
  await Promise.all(
    entry.map((ent) =>
      loadModule(ent, null, allLoaded, loadedNpmPkgs, opts, taskId).catch(
        (e) => {
          e.message = `${ent}: ${e.message}`;
          throw e;
        }
      )
    )
  );
  opts.debug && console.log('All loaded modules', allLoaded);
  return allLoaded;
}

// https://nodejs.org/api/modules.html#modules_all_together
async function loadModule(
  requirePath: string,
  from: LoadedModule | null,
  allModules: LoadedModules,
  pkgs: any,
  opts: IOptions,
  taskId: number
) {
  if (!taskCtrl.isActive(taskId)) {
    return;
  }
  let loadedMod: LoadedModule;
  // # core module
  if (coreModules[requirePath]) {
    console.log('load core module', requirePath);
    return;
  }

  let absoluteReqPath = requirePath; // can be alias
  if (absoluteReqPath.startsWith('.')) {
    absoluteReqPath = url.resolve(from.id, absoluteReqPath);
  }
  if (absoluteReqPath.endsWith('/')) {
    // normalize folder module
    absoluteReqPath = absoluteReqPath.substr(0, absoluteReqPath.length - 1);
  }

  const possibleModuleIds = addModuleExts(absoluteReqPath, opts.extensions);

  // check loaded
  for (const id of possibleModuleIds) {
    if (allModules[id]) {
      return allModules[id];
    }
  }

  // # try all extensions from preload
  for (const id of possibleModuleIds) {
    if (opts.preload[id]) {
      // TODO set up alias
      loadedMod = {
        id,
        url: id,
        code: opts.preload[id],
        requires: {},
        alias: [],
      };
      break;
    }
  }

  if (!loadedMod) {
    let res;
    if (absoluteReqPath.startsWith('/')) {
      // Load from current project
      try {
        res = await loadModuleAsFile(absoluteReqPath, opts.projFilePrefix);
        if (res) {
          loadedMod = {
            id: absoluteReqPath,
            url: res.url,
            code: res.content,
            requires: {},
            alias: [],
          };
        }
      } catch (e) {
        e.absoluteReqPath = absoluteReqPath;
        throw e;
      }
    } else {
      // node_modules
      const fromPkg = getPackageName(from.id);
      const curPkg = getPackageName(absoluteReqPath);
      const curPkgInfo = pkgs[curPkg];
      const scope = curPkg.split('/')[0];

      let versionRange = '';
      if (curPkgInfo) {
        // already loaded package, use loaded package version
        versionRange = curPkgInfo.version;
      } else if (fromPkg) {
        // import by another module
        versionRange = pkgs[fromPkg].dependencies[curPkg];
      } else {
        // import by current project
        versionRange = opts.dependencies[curPkg];
      }

      // Resovle version by version range
      let version;
      try {
        version = versionRange ? minVersion(versionRange) : versionRange;
      } catch (e) {
        opts.onError(
          new Error(
            `Package(${curPkg}@${versionRange}) version error, specific version required.`
          )
        );
      }

      const res = await loadNodeModule(
        absoluteReqPath,
        opts.npmFilePrefixByScope[scope] || opts.npmFilePrefix,
        pkgs,
        opts.extensions,
        version
      );
      if (res) {
        if (!versionRange) {
          opts.onDependencyMiss &&
            opts.onDependencyMiss(curPkg, pkgs[curPkg].version);
        }
        loadedMod = {
          id: res.id,
          url: res.url,
          code: res.content,
          alias: res.alias,
          requires: {},
        };
      }
    }
  }

  if (!loadedMod) {
    console.error('Fail to load', requirePath, 'from', from);
    return;
  }

  if (allModules[loadedMod.id]) {
    console.warn(`Duplicated load of module ${requirePath} from ${from.id}`);
  } else {
    opts.debug && console.log('Module loaded', loadedMod.id);
  }
  allModules[loadedMod.id] = loadedMod;
  loadedMod.alias.forEach((a) => {
    allModules[a] = loadedMod;
  });
  const deps = parseDependencies(loadedMod.code);
  for (const dep of deps) {
    const mod = await loadModule(
      dep.path,
      loadedMod,
      allModules,
      pkgs,
      opts,
      taskId
    );
    if (mod) {
      loadedMod.requires[dep.path] = mod.id;
    }
  }

  return loadedMod;
}

async function loadModuleAsFile(uri: string, urlPrefix: string) {
  const res = await fetch(urlPrefix + uri);
  const content = await res.text();
  return { content, url: res.url };
}

function getPackageName(uri: string) {
  const parts = uri.split('/');
  const partCount = uri.startsWith('@') ? 2 : 1;
  return parts.slice(0, partCount).join('/');
}

/**
 * load package name without version
 * load package name with version
 * npm and relative import in modules
 */
async function loadNodeModule(
  uri: string,
  urlPrefix: string,
  pkgs: any,
  exts: string[],
  version?: string
) {
  /**
   * do not support custom node_modules
   */
  throw new Error(
    `Package(${uri}@${version}) load fail, do not support  custom_node modules`
  );

  // TODO exist same package import
  const pkg = getPackageName(uri);
  let pkgInfo = pkgs[pkg];
  let pkgUri = `${urlPrefix}${pkg}${version ? `@${version}` : ''}`;
  if (!pkgInfo) {
    pkgInfo = await fetch(`${pkgUri}/package.json`)
      .then((res) => res.json())
      .catch((e) => null);
    if (!pkgInfo) {
      return null; // package not exist
    }
    pkgs[pkg] = pkgInfo;
    version = pkgInfo.version;
    pkgUri = `${urlPrefix}${pkg}@${version}`;
  }

  let pathInPkg = uri.substr(pkg.length);
  const { browser } = pkgInfo; // https://github.com/defunctzombie/package-browser-field-spec
  if (!pathInPkg) {
    pathInPkg =
      (typeof browser === 'string' ? browser : pkgInfo.main) || 'index.js';
  } else if (browser && browser[pathInPkg]) {
    pathInPkg = browser[pathInPkg];
  }

  if (pathInPkg.startsWith('.')) {
    pathInPkg = pathInPkg.substr(1); // Remove the first dot
  } else if (!pathInPkg.startsWith('/')) {
    pathInPkg = `/${pathInPkg}`;
  }

  const res = await fetchModule(pkgUri + pathInPkg, exts);
  if (!res) {
    return null;
  }
  const alias: string[] = [];
  if (pkg === uri || res.isFolder) {
    alias.push(uri); // package name & folder is alias
  }

  pathInPkg = res.url.substr(pkgUri.length); // Get real path of in package
  return {
    content: res.content,
    id: `${pkg}${pathInPkg}`,
    url: res.url,
    alias,
    version: pkgInfo.version,
  };
}

/**
 * fetch specifed module path, if it's a folder try load package.json and index.js in the folder
 * @param modPath e.g. antd/lib/index
 * @param baseUrl cdn host base url
 * @param tryExts extensions to try
 */
async function fetchModule(
  modUrl: string,
  tryExts: string[],
  fromFolder = false
) {
  const withExt = !!path.extname(modUrl);
  if (withExt) {
    const res = await fetch(modUrl);
    if (res.status !== 404) {
      return {
        content: await res.text(),
        url: res.url,
      };
    }
  }
  let extAdded = '';
  let res: Response;
  for (const ext of tryExts) {
    res = await fetch(modUrl + ext);
    if (res.status !== 404) {
      extAdded = ext;
      break;
    }
  }

  if (!extAdded && !fromFolder) {
    // try as folder
    // TODO load pacakge.json to find main
    const pkgInfoRes = await fetch(path.join(modUrl, 'package.json'));
    let fileInFolder = '';
    if (pkgInfoRes.status !== 404) {
      const pkgInfo = await pkgInfoRes.json();
      fileInFolder = pkgInfo.main;
    }
    if (!fileInFolder) {
      fileInFolder = 'index';
    } else if (fileInFolder.startsWith('.')) {
      fileInFolder = fileInFolder.substr(1);
    }
    return fetchModule(path.join(modUrl, fileInFolder), tryExts, true);
  }

  if (res.status === 404) {
    return null;
  }
  return {
    content: await res.text(),
    url: res.url,
    extAdded,
    isFolder: fromFolder,
  };
}

function addModuleExts(modBase: string, possibleExts: string[]) {
  if (isNpmPackage(modBase)) {
    return [modBase];
  }
  return possibleExts.concat(['']).map((ext) => modBase + ext);
}

function isNpmPackage(pkg: string) {
  const parts = pkg.split('/');
  return parts.length === 1 || (parts[0].startsWith('@') && parts.length === 2);
}
