/*!
 * Copyright 2019 acrazing <joking.young@gmail.com>. All rights reserved.
 * @since 2019-07-17 18:45:32
 */

import chalk from 'chalk';
import fs from 'fs-extra';
import { builtinModules } from 'module';
import path from 'path';
import { DependencyKind } from './consts';
import { Dependency, DependencyTree, ParseOptions } from './types';

const allBuiltins = new Set(builtinModules);

export type SkippedImport = readonly [string, string];

function createSkippedImportsRegExp(
  skipImports: readonly SkippedImport[],
): RegExp {
  if (skipImports.length === 0) {
    return /$./;
  }
  return new RegExp(
    `^(?:${skipImports.map((item) => item.join(':')).join('|')})$`,
  );
}

export const defaultOptions: ParseOptions = {
  cwd: process.cwd(),
  context: process.cwd(),
  extensions: ['', '.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'],
  js: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
  include: /.*/,
  exclude: /node_modules/,
  tsconfig: void 0,
  transform: false,
  skipDynamicImports: false,
  onProgress: () => void 0,
};

export function normalizeOptions(options: Partial<ParseOptions>): ParseOptions {
  const newOptions = { ...defaultOptions, ...options };
  newOptions.cwd = path.resolve(options.cwd || process.cwd());
  newOptions.context = path.resolve(newOptions.cwd, options.context || '.');
  if (newOptions.extensions.indexOf('') < 0) {
    newOptions.extensions.unshift('');
  }
  if (options.tsconfig === void 0) {
    try {
      const tsconfig = path.join(newOptions.context, 'tsconfig.json');
      const stat = fs.statSync(tsconfig);
      if (stat.isFile()) {
        newOptions.tsconfig = tsconfig;
      }
    } catch {}
  } else {
    const tsconfig = path.resolve(newOptions.cwd, options.tsconfig);
    let stat: fs.Stats | undefined;
    try {
      stat = fs.statSync(tsconfig);
    } catch {}
    if (!stat || !stat.isFile()) {
      throw new Error(`specified tsconfig "${options.tsconfig}" is not a file`);
    }
    newOptions.tsconfig = tsconfig;
  }
  return newOptions;
}

export async function appendSuffix(
  request: string,
  extensions: string[],
): Promise<string | null> {
  for (const ext of extensions) {
    try {
      const stat = await fs.stat(request + ext);
      if (stat.isFile()) {
        return request + ext;
      }
    } catch {}
  }
  try {
    const stat = await fs.stat(request);
    if (stat.isDirectory()) {
      return appendSuffix(path.join(request, 'index'), extensions);
    }
  } catch {}
  return null;
}

export type Resolver = (
  context: string,
  request: string,
  extensions: string[],
) => Promise<string | null>;

export const simpleResolver: Resolver = async (
  context: string,
  request: string,
  extensions: string[],
) => {
  if (path.isAbsolute(request)) {
    return appendSuffix(request, extensions);
  }
  if (request.charAt(0) === '.') {
    return appendSuffix(path.join(context, request), extensions);
  }
  // is package
  const nodePath = { paths: [context] };
  try {
    const pkgPath = require.resolve(
      path.join(request, 'package.json'),
      nodePath,
    );
    const pkgJson = await fs.readJSON(pkgPath);
    const id = path.join(path.dirname(pkgPath), pkgJson.module || pkgJson.main);
    return appendSuffix(id, extensions);
  } catch {}
  try {
    return require.resolve(request, nodePath);
  } catch {}
  return null;
};

export function shortenTree(
  context: string,
  tree: DependencyTree,
): DependencyTree {
  const output: DependencyTree = {};
  for (const key in tree) {
    const shortKey = path.relative(context, key);
    output[shortKey] = tree[key]
      ? tree[key]!.map(
          (item) =>
            ({
              ...item,
              issuer: shortKey,
              id: item.id === null ? null : path.relative(context, item.id),
            }) as Dependency,
        )
      : null;
  }
  return output;
}

function getPackageNameFromRequest(request: string): string | null {
  if (request.startsWith('.') || path.isAbsolute(request)) {
    return null;
  }
  const parts = request.split('/');
  if (request.startsWith('@')) {
    return parts.length > 1 ? parts.slice(0, 2).join('/') : request;
  }
  return parts[0] || null;
}

function getPackageNameFromPath(
  context: string,
  id: string,
  cache: Map<string, string | null>,
): string | null {
  if (allBuiltins.has(id)) {
    return id;
  }
  const fullPath = path.isAbsolute(id) ? id : path.resolve(context, id);
  let current = path.extname(fullPath) ? path.dirname(fullPath) : fullPath;
  const root = path.parse(current).root;

  while (true) {
    const cached = cache.get(current);
    if (cached !== void 0) {
      return cached;
    }

    try {
      const pkg = fs.readJSONSync(path.join(current, 'package.json'));
      const name =
        typeof pkg.name === 'string' && pkg.name
          ? pkg.name
          : path.relative(context, current) || path.basename(current);
      cache.set(current, name);
      return name;
    } catch {}

    if (current === root) {
      cache.set(current, null);
      return null;
    }
    current = path.dirname(current);
  }
}

export function getPackageName(context: string, id: string): string | null {
  return getPackageNameFromPath(context, id, new Map());
}

export function groupDependencyTreeByPackage(
  tree: DependencyTree,
  context: string,
): DependencyTree {
  const packages: Record<string, Dependency[] | null> = {};
  const edges: Record<string, Set<string>> = {};
  const cache = new Map<string, string | null>();

  function ensurePackage(id: string, ignored = false) {
    if (!(id in packages)) {
      packages[id] = ignored ? null : [];
    } else if (packages[id] === null && !ignored) {
      packages[id] = [];
    }
  }

  for (const id in tree) {
    const issuerPackage = getPackageNameFromPath(context, id, cache) || id;
    const deps = tree[id];
    ensurePackage(issuerPackage, deps === null);
    if (!deps) {
      continue;
    }

    for (const dep of deps) {
      const dependencyPackage = dep.id
        ? getPackageNameFromPath(context, dep.id, cache)
        : getPackageNameFromRequest(dep.request);
      if (!dependencyPackage || dependencyPackage === issuerPackage) {
        continue;
      }

      ensurePackage(dependencyPackage, dep.id ? tree[dep.id] === null : false);
      const edgeSet = (edges[issuerPackage] =
        edges[issuerPackage] || new Set());
      if (edgeSet.has(dependencyPackage)) {
        continue;
      }
      edgeSet.add(dependencyPackage);
      (packages[issuerPackage] as Dependency[]).push({
        issuer: issuerPackage,
        request: dep.request,
        kind: dep.kind,
        id: dependencyPackage,
      });
    }
  }

  for (const id in packages) {
    packages[id]?.sort((a, b) => a.id!.localeCompare(b.id!));
  }
  return packages;
}

export function groupEntriesByPackage(
  entries: string[],
  context: string,
): string[] {
  const output: string[] = [];
  const seen = new Set<string>();
  const cache = new Map<string, string | null>();
  for (const entry of entries) {
    const id = getPackageNameFromPath(context, entry, cache) || entry;
    if (!seen.has(id)) {
      output.push(id);
      seen.add(id);
    }
  }
  return output;
}

export function parseCircular(
  tree: DependencyTree,
  skipDynamicImports: boolean = false,
  skipImports: readonly SkippedImport[] = [],
): string[][] {
  const circulars: string[][] = [];
  const skippedImports = createSkippedImportsRegExp(skipImports);

  tree = { ...tree };

  function visit(id: string, used: string[]) {
    const index = used.indexOf(id);
    if (index > -1) {
      circulars.push(used.slice(index));
    } else if (tree[id]) {
      used.push(id);
      const deps = tree[id];
      delete tree[id];
      deps &&
        deps.forEach((dep) => {
          if (
            dep.id &&
            (!skipDynamicImports ||
              dep.kind !== DependencyKind.DynamicImport) &&
            !skippedImports.test(`${dep.issuer}:${dep.id}`)
          ) {
            visit(dep.id, used.slice());
          }
        });
    }
  }

  for (const id in tree) {
    visit(id, []);
  }
  return circulars;
}

export function parseDependents(
  tree: DependencyTree,
): Record<string, string[]> {
  const output: Record<string, string[]> = {};
  for (const key in tree) {
    const deps = tree[key];
    if (deps) {
      deps.forEach((dep) => {
        if (dep.id) {
          (output[dep.id] = output[dep.id] || []).push(key);
        }
      });
    }
  }
  for (const key in output) {
    output[key].sort();
  }
  return output;
}

export function parseWarnings(
  tree: DependencyTree,
  dependents = parseDependents(tree),
): string[] {
  const warnings: string[] = [];
  const builtin = new Set<string>();
  for (const key in tree) {
    const deps = tree[key];
    if (!builtin.has(key) && allBuiltins.has(key)) {
      builtin.add(key);
    }
    if (!deps) {
      const parents = dependents[key] || [];
      const total = parents.length;
      warnings.push(
        `skip ${JSON.stringify(key)}, issuers: ${parents
          .slice(0, 2)
          .map((id) => JSON.stringify(id))
          .join(', ')}${total > 2 ? ` (${total - 2} more...)` : ''}`,
      );
    } else {
      for (const dep of deps) {
        if (!dep.id) {
          warnings.push(
            `miss ${JSON.stringify(dep.request)} in ${JSON.stringify(
              dep.issuer,
            )}`,
          );
        }
      }
    }
  }
  if (builtin.size > 0) {
    warnings.push(
      'node ' + Array.from(builtin, (item) => JSON.stringify(item)).join(', '),
    );
  }
  return warnings.sort();
}

export function prettyTree(
  tree: DependencyTree,
  entries: string[],
  prefix = '  ',
) {
  const lines: string[] = [];
  let id = 0;
  const idMap: Record<string, number> = {};
  const digits = Math.ceil(Math.log10(Object.keys(tree).length));

  function visit(item: string, prefix: string, hasMore: boolean) {
    const isNew = idMap[item] === void 0;
    const iid = (idMap[item] = idMap[item] || id++);
    let line = chalk.gray(
      prefix + '- ' + iid.toString().padStart(digits, '0') + ') ',
    );
    const deps = tree[item];
    if (allBuiltins.has(item)) {
      lines.push(line + chalk.blue(item));
      return;
    } else if (!isNew) {
      lines.push(line + chalk.gray(item));
      return;
    } else if (!deps) {
      lines.push(line + chalk.yellow(item));
      return;
    }
    lines.push(line + item);
    prefix += hasMore ? '·   ' : '    ';
    for (let i = 0; i < deps.length; i++) {
      visit(deps[i].id || deps[i].request, prefix, i < deps.length - 1);
    }
  }

  for (let i = 0; i < entries.length; i++) {
    visit(entries[i], prefix, i < entries.length - 1);
  }

  return lines.join('\n');
}

export function prettyCircular(circulars: string[][], prefix = '  ') {
  const digits = Math.ceil(Math.log10(circulars.length));
  return circulars
    .map((line, index) => {
      return (
        chalk.gray(
          `${prefix}${(index + 1).toString().padStart(digits, '0')}) `,
        ) + line.map((item) => chalk.red(item)).join(chalk.gray(' -> '))
      );
    })
    .join('\n');
}

export function prettyWarning(warnings: string[], prefix = '  ') {
  const digits = Math.ceil(Math.log10(warnings.length));
  return warnings
    .map((line, index) => {
      return (
        chalk.gray(
          `${prefix}${(index + 1).toString().padStart(digits, '0')}) `,
        ) + chalk.yellow(line)
      );
    })
    .join('\n');
}

export function isEmpty(v: unknown) {
  if (v == null) {
    return true;
  }
  for (const k in v) {
    if (v.hasOwnProperty(k)) {
      return false;
    }
  }
  return true;
}
