const crypto = require('crypto');
const fs = require('graceful-fs');
const path = require('path');

const lodash = require('lodash');
const _mkdirp = require('mkdirp');
const _rimraf = require('rimraf');
const nodeObjectHash = require('node-object-hash');
const findCacheDir = require('find-cache-dir');

const envHash = require('./lib/envHash');
const defaultConfigHash = require('./lib/defaultConfigHash');
const promisify = require('./lib/util/promisify');
const relateContext = require('./lib/util/relate-context');
const pluginCompat = require('./lib/util/plugin-compat');
const logMessages = require('./lib/util/log-messages');

const LoggerFactory = require('./lib/loggerFactory');

const cachePrefix = require('./lib/util').cachePrefix;

const CacheSerializerFactory = require('./lib/CacheSerializerFactory');
const ExcludeModulePlugin = require('./lib/ExcludeModulePlugin');
const HardSourceLevelDbSerializerPlugin = require('./lib/SerializerLeveldbPlugin');
const SerializerAppend2Plugin = require('./lib/SerializerAppend2Plugin');
const SerializerAppendPlugin = require('./lib/SerializerAppendPlugin');
const SerializerCacachePlugin = require('./lib/SerializerCacachePlugin');
const SerializerJsonPlugin = require('./lib/SerializerJsonPlugin');

const hardSourceVersion = require('./package.json').version;

function requestHash(request) {
  return crypto
    .createHash('sha1')
    .update(request)
    .digest()
    .hexSlice();
}

const mkdirp = promisify(_mkdirp, { context: _mkdirp });
mkdirp.sync = _mkdirp.sync.bind(_mkdirp);
const rimraf = promisify(_rimraf);
rimraf.sync = _rimraf.sync.bind(_rimraf);
const fsReadFile = promisify(fs.readFile, { context: fs });
const fsWriteFile = promisify(fs.writeFile, { context: fs });

const bulkFsTask = (array, each) =>
  new Promise((resolve, reject) => {
    let ops = 0;
    const out = [];
    array.forEach((item, i) => {
      out[i] = each(item, (back, callback) => {
        ops++;
        return (err, value) => {
          try {
            out[i] = back(err, value, out[i]);
          } catch (e) {
            return reject(e);
          }

          ops--;
          if (ops === 0) {
            resolve(out);
          }
        };
      });
    });
    if (ops === 0) {
      resolve(out);
    }
  });

const compilerContext = relateContext.compilerContext;
const relateNormalPath = relateContext.relateNormalPath;
const contextNormalPath = relateContext.contextNormalPath;
const contextNormalPathSet = relateContext.contextNormalPathSet;

function relateNormalRequest(compiler, key) {
  return key
    .split('!')
    .map(subkey => relateNormalPath(compiler, subkey))
    .join('!');
}

function relateNormalModuleId(compiler, id) {
  return id.substring(0, 24) + relateNormalRequest(compiler, id.substring(24));
}

function contextNormalRequest(compiler, key) {
  return key
    .split('!')
    .map(subkey => contextNormalPath(compiler, subkey))
    .join('!');
}

function contextNormalModuleId(compiler, id) {
  return id.substring(0, 24) + contextNormalRequest(compiler, id.substring(24));
}

function contextNormalLoaders(compiler, loaders) {
  return loaders.map(loader =>
    Object.assign({}, loader, {
      loader: contextNormalPath(compiler, loader.loader),
    }),
  );
}

function contextNormalPathArray(compiler, paths) {
  return paths.map(subpath => contextNormalPath(compiler, subpath));
}

class HardSourceWebpackPlugin {
  constructor(options) {
    this.options = options || {};
  }

  getPath(dirName, suffix) {
    const confighashIndex = dirName.search(/\[confighash\]/);
    if (confighashIndex !== -1) {
      dirName = dirName.replace(/\[confighash\]/, this.configHash);
    }
    let cachePath = path.resolve(
      process.cwd(),
      this.compilerOutputOptions.path,
      dirName,
    );
    if (suffix) {
      cachePath = path.join(cachePath, suffix);
    }
    return cachePath;
  }

  getCachePath(suffix) {
    return this.getPath(this.options.cacheDirectory, suffix);
  }

  apply(compiler) {
    const options = this.options;
    let active = true;

    const logger = new LoggerFactory(compiler).create();

    const loggerCore = logger.from('core');
    logger.lock();

    const compilerHooks = pluginCompat.hooks(compiler);

    if (!compiler.options.cache) {
      compiler.options.cache = true;
    }

    if (!options.cacheDirectory) {
      options.cacheDirectory = path.resolve(
        findCacheDir({
          name: 'hard-source',
          cwd: compiler.options.context || process.cwd(),
        }),
        '[confighash]',
      );
    }

    this.compilerOutputOptions = compiler.options.output;
    if (!options.configHash) {
      options.configHash = defaultConfigHash;
    }
    if (options.configHash) {
      if (typeof options.configHash === 'string') {
        this.configHash = options.configHash;
      } else if (typeof options.configHash === 'function') {
        this.configHash = options.configHash(compiler.options);
      }
      compiler.__hardSource_configHash = this.configHash;
      compiler.__hardSource_shortConfigHash = this.configHash.substring(0, 8);
    }
    const configHashInDirectory =
      options.cacheDirectory.search(/\[confighash\]/) !== -1;
    if (configHashInDirectory && !this.configHash) {
      logMessages.configHashSetButNotUsed(compiler, {
        cacheDirectory: options.cacheDirectory,
      });
      active = false;

      function unlockLogger() {
        logger.unlock();
      }
      compilerHooks.watchRun.tap('HardSource - index', unlockLogger);
      compilerHooks.run.tap('HardSource - index', unlockLogger);
      return;
    }

    let environmentHasher = null;
    if (typeof options.environmentHash !== 'undefined') {
      if (options.environmentHash === false) {
        environmentHasher = () => Promise.resolve('');
      } else if (typeof options.environmentHash === 'string') {
        environmentHasher = () => Promise.resolve(options.environmentHash);
      } else if (typeof options.environmentHash === 'object') {
        environmentHasher = () => envHash(options.environmentHash);
        environmentHasher.inputs = () =>
          envHash.inputs(options.environmentHash);
      } else if (typeof options.environmentHash === 'function') {
        environmentHasher = () => Promise.resolve(options.environmentHash());
        if (options.environmentHash.inputs) {
          environmentHasher.inputs = () =>
            Promise.resolve(options.environmentHasher.inputs());
        }
      }
    }
    if (!environmentHasher) {
      environmentHasher = envHash;
    }

    const cacheDirPath = this.getCachePath();
    const cacheAssetDirPath = path.join(cacheDirPath, 'assets');
    const resolveCachePath = path.join(cacheDirPath, 'resolve.json');

    let currentStamp = '';

    const cacheSerializerFactory = new CacheSerializerFactory(compiler);
    let createSerializers = true;
    let cacheRead = false;

    const _this = this;

    pluginCompat.register(compiler, '_hardSourceCreateSerializer', 'sync', [
      'cacheSerializerFactory',
      'cacheDirPath',
    ]);
    pluginCompat.register(compiler, '_hardSourceResetCache', 'sync', []);
    pluginCompat.register(compiler, '_hardSourceReadCache', 'asyncParallel', [
      'relativeHelpers',
    ]);
    pluginCompat.register(
      compiler,
      '_hardSourceVerifyCache',
      'asyncParallel',
      [],
    );
    pluginCompat.register(compiler, '_hardSourceWriteCache', 'asyncParallel', [
      'compilation',
      'relativeHelpers',
    ]);

    if (configHashInDirectory) {
      const PruneCachesSystem = require('./lib/SystemPruneCaches');

      new PruneCachesSystem(
        path.dirname(cacheDirPath),
        options.cachePrune,
      ).apply(compiler);
    }

    function runReadOrReset(_compiler) {
      logger.unlock();

      if (!active) {
        return Promise.resolve();
      }

      try {
        fs.statSync(cacheAssetDirPath);
      } catch (_) {
        mkdirp.sync(cacheAssetDirPath);
        logMessages.configHashFirstBuild(compiler, {
          cacheDirPath,
          configHash: compiler.__hardSource_configHash,
        });
      }
      const start = Date.now();

      if (createSerializers) {
        createSerializers = false;
        try {
          compilerHooks._hardSourceCreateSerializer.call(
            cacheSerializerFactory,
            cacheDirPath,
          );
        } catch (err) {
          return Promise.reject(err);
        }
      }

      return Promise.all([
        fsReadFile(path.join(cacheDirPath, 'stamp'), 'utf8').catch(() => ''),

        environmentHasher(),

        fsReadFile(path.join(cacheDirPath, 'version'), 'utf8').catch(() => ''),

        environmentHasher.inputs ? environmentHasher.inputs() : null,
      ]).then(([stamp, hash, versionStamp, hashInputs]) => {
        if (!configHashInDirectory && options.configHash) {
          hash += `_${_this.configHash}`;
        }

        if (hashInputs && !cacheRead) {
          logMessages.environmentInputs(compiler, { inputs: hashInputs });
        }

        currentStamp = hash;
        if (!hash || hash !== stamp || hardSourceVersion !== versionStamp) {
          if (hash && stamp) {
            if (configHashInDirectory) {
              logMessages.environmentHashChanged(compiler);
            } else {
              logMessages.configHashChanged(compiler);
            }
          } else if (versionStamp && hardSourceVersion !== versionStamp) {
            logMessages.hardSourceVersionChanged(compiler);
          }

          // Reset the cache, we can't use it do to an environment change.
          pluginCompat.call(compiler, '_hardSourceResetCache', []);

          return rimraf(cacheDirPath);
        }

        if (cacheRead) {
          return Promise.resolve();
        }
        cacheRead = true;

        logMessages.configHashBuildWith(compiler, {
          cacheDirPath,
          configHash: compiler.__hardSource_configHash,
        });

        function contextKeys(compiler, fn) {
          return source => {
            const dest = {};
            Object.keys(source).forEach(key => {
              dest[fn(compiler, key)] = source[key];
            });
            return dest;
          };
        }

        function contextValues(compiler, fn) {
          return source => {
            const dest = {};
            Object.keys(source).forEach(key => {
              const value = fn(compiler, source[key], key);
              if (value) {
                dest[key] = value;
              } else {
                delete dest[key];
              }
            });
            return dest;
          };
        }

        function copyWithDeser(dest, source) {
          Object.keys(source).forEach(key => {
            const item = source[key];
            dest[key] = typeof item === 'string' ? JSON.parse(item) : item;
          });
        }

        return Promise.all([
          compilerHooks._hardSourceReadCache.promise({
            contextKeys,
            contextValues,
            contextNormalPath,
            contextNormalRequest,
            contextNormalModuleId,
            copyWithDeser,
          }),
        ])
          .catch(error => {
            logMessages.serialBadCache(compiler, error);

            return rimraf(cacheDirPath);
          })
          .then(() => {
            // console.log('cache in', Date.now() - start);
          });
      });
    }

    compilerHooks.watchRun.tapPromise(
      'HardSource - index - readOrReset',
      runReadOrReset,
    );
    compilerHooks.run.tapPromise(
      'HardSource - index - readOrReset',
      runReadOrReset,
    );

    const detectModule = path => {
      try {
        require(path);
        return true;
      } catch (_) {
        return false;
      }
    };

    const webpackFeatures = {
      concatenatedModule: detectModule(
        'webpack/lib/optimize/ConcatenatedModule',
      ),
      generator: detectModule('webpack/lib/JavascriptGenerator'),
    };

    let schemasVersion = 2;
    if (webpackFeatures.concatenatedModule) {
      schemasVersion = 3;
    }
    if (webpackFeatures.generator) {
      schemasVersion = 4;
    }

    const ArchetypeSystem = require('./lib/SystemArchetype');
    const ParitySystem = require('./lib/SystemParity');

    const AssetCache = require('./lib/CacheAsset');
    const ModuleCache = require('./lib/CacheModule');

    const EnhancedResolveCache = require('./lib/CacheEnhancedResolve');
    const Md5Cache = require('./lib/CacheMd5');
    const ModuleResolverCache = require('./lib/CacheModuleResolver');

    const TransformCompilationPlugin = require('./lib/TransformCompilationPlugin');
    const TransformAssetPlugin = require('./lib/TransformAssetPlugin');
    let TransformConcatenationModulePlugin;
    if (webpackFeatures.concatenatedModule) {
      TransformConcatenationModulePlugin = require('./lib/TransformConcatenationModulePlugin');
    }
    const TransformNormalModulePlugin = require('./lib/TransformNormalModulePlugin');
    const TransformNormalModuleFactoryPlugin = require('./lib/TransformNormalModuleFactoryPlugin');
    const TransformModuleAssetsPlugin = require('./lib/TransformModuleAssetsPlugin');
    const TransformModuleErrorsPlugin = require('./lib/TransformModuleErrorsPlugin');
    const SupportExtractTextPlugin = require('./lib/SupportExtractTextPlugin');
    let SupportMiniCssExtractPlugin;
    if (webpackFeatures.generator) {
      SupportMiniCssExtractPlugin = require('./lib/SupportMiniCssExtractPlugin');
    }
    const TransformDependencyBlockPlugin = require('./lib/TransformDependencyBlockPlugin');
    const TransformBasicDependencyPlugin = require('./lib/TransformBasicDependencyPlugin');
    let HardHarmonyDependencyPlugin;
    const TransformSourcePlugin = require('./lib/TransformSourcePlugin');
    const TransformParserPlugin = require('./lib/TransformParserPlugin');
    let TransformGeneratorPlugin;
    if (webpackFeatures.generator) {
      TransformGeneratorPlugin = require('./lib/TransformGeneratorPlugin');
    }

    const ChalkLoggerPlugin = require('./lib/ChalkLoggerPlugin');

    new ArchetypeSystem().apply(compiler);
    new ParitySystem().apply(compiler);

    new AssetCache().apply(compiler);
    new ModuleCache().apply(compiler);

    new EnhancedResolveCache().apply(compiler);
    new Md5Cache().apply(compiler);
    new ModuleResolverCache().apply(compiler);

    new TransformCompilationPlugin().apply(compiler);

    new TransformAssetPlugin().apply(compiler);

    new TransformNormalModulePlugin({
      schema: schemasVersion,
    }).apply(compiler);
    new TransformNormalModuleFactoryPlugin().apply(compiler);

    if (TransformConcatenationModulePlugin) {
      new TransformConcatenationModulePlugin().apply(compiler);
    }

    new TransformModuleAssetsPlugin().apply(compiler);
    new TransformModuleErrorsPlugin().apply(compiler);
    new SupportExtractTextPlugin().apply(compiler);

    if (SupportMiniCssExtractPlugin) {
      new SupportMiniCssExtractPlugin().apply(compiler);
    }

    new TransformDependencyBlockPlugin({
      schema: schemasVersion,
    }).apply(compiler);

    new TransformBasicDependencyPlugin({
      schema: schemasVersion,
    }).apply(compiler);

    new TransformSourcePlugin({
      schema: schemasVersion,
    }).apply(compiler);

    new TransformParserPlugin({
      schema: schemasVersion,
    }).apply(compiler);

    if (TransformGeneratorPlugin) {
      new TransformGeneratorPlugin({
        schema: schemasVersion,
      }).apply(compiler);
    }

    new ChalkLoggerPlugin(this.options.info).apply(compiler);

    function runVerify(_compiler) {
      if (!active) {
        return Promise.resolve();
      }

      const stats = {};
      return pluginCompat.promise(compiler, '_hardSourceVerifyCache', []);
    }

    compilerHooks.watchRun.tapPromise('HardSource - index - verify', runVerify);
    compilerHooks.run.tapPromise('HardSource - index - verify', runVerify);

    let freeze;

    compilerHooks._hardSourceMethods.tap('HardSource - index', methods => {
      freeze = methods.freeze;
    });

    compilerHooks.afterCompile.tapPromise('HardSource - index', compilation => {
      if (!active) {
        return Promise.resolve();
      }

      const startCacheTime = Date.now();

      const identifierPrefix = cachePrefix(compilation);
      if (identifierPrefix !== null) {
        freeze('Compilation', null, compilation, {
          compilation,
        });
      }

      return Promise.all([
        mkdirp(cacheDirPath).then(() =>
          Promise.all([
            fsWriteFile(path.join(cacheDirPath, 'stamp'), currentStamp, 'utf8'),
            fsWriteFile(
              path.join(cacheDirPath, 'version'),
              hardSourceVersion,
              'utf8',
            ),
          ]),
        ),
        pluginCompat.promise(compiler, '_hardSourceWriteCache', [
          compilation,
          {
            relateNormalPath,
            relateNormalRequest,
            relateNormalModuleId,

            contextNormalPath,
            contextNormalRequest,
            contextNormalModuleId,
          },
        ]),
      ]).then(() => {
        // console.log('cache out', Date.now() - startCacheTime);
      });
    });
  }
}

module.exports = HardSourceWebpackPlugin;

HardSourceWebpackPlugin.ExcludeModulePlugin = ExcludeModulePlugin;
HardSourceWebpackPlugin.HardSourceLevelDbSerializerPlugin = HardSourceLevelDbSerializerPlugin;
HardSourceWebpackPlugin.LevelDbSerializerPlugin = HardSourceLevelDbSerializerPlugin;
HardSourceWebpackPlugin.SerializerAppend2Plugin = SerializerAppend2Plugin;
HardSourceWebpackPlugin.SerializerAppendPlugin = SerializerAppendPlugin;
HardSourceWebpackPlugin.SerializerCacachePlugin = SerializerCacachePlugin;
HardSourceWebpackPlugin.SerializerJsonPlugin = SerializerJsonPlugin;

Object.defineProperty(HardSourceWebpackPlugin, 'ParallelModulePlugin', {
  get() {
    return require('./lib/ParallelModulePlugin');
  },
});
