// @flow strict-local

import type {
  Blob,
  FilePath,
  BundleResult,
  Bundle as BundleType,
  BundleGraph as BundleGraphType,
  NamedBundle as NamedBundleType,
  Async,
} from '@parcel/types';
import type SourceMap from '@parcel/source-map';
import type {
  Bundle as InternalBundle,
  Config,
  DevDepRequest,
  DevDepRequestRef,
  ParcelOptions,
  ReportFn,
  RequestInvalidation,
} from './types';
import type ParcelConfig, {LoadedPlugin} from './ParcelConfig';
import type InternalBundleGraph from './BundleGraph';
import type {ConfigRequest} from './requests/ConfigRequest';
import type {DevDepSpecifier} from './requests/DevDepRequest';

import invariant from 'assert';
import {blobToStream, bufferStream, TapStream} from '@parcel/utils';
import {PluginLogger} from '@parcel/logger';
import ThrowableDiagnostic, {errorToDiagnostic} from '@parcel/diagnostic';
import {Readable} from 'stream';
import nullthrows from 'nullthrows';
import path from 'path';
import url from 'url';
import {hashString, hashBuffer, Hash} from '@parcel/rust';

import {NamedBundle, bundleToInternalBundle} from './public/Bundle';
import BundleGraph, {
  bundleGraphToInternalBundleGraph,
} from './public/BundleGraph';
import PluginOptions from './public/PluginOptions';
import PublicConfig from './public/Config';
import {PARCEL_VERSION, HASH_REF_PREFIX, HASH_REF_REGEX} from './constants';
import {
  fromProjectPath,
  toProjectPathUnsafe,
  fromProjectPathRelative,
  joinProjectPath,
} from './projectPath';
import {createConfig} from './InternalConfig';
import {
  loadPluginConfig,
  getConfigHash,
  getConfigRequests,
  type PluginWithBundleConfig,
} from './requests/ConfigRequest';
import {
  createDevDependency,
  getWorkerDevDepRequests,
} from './requests/DevDepRequest';
import {createBuildCache} from './buildCache';
import {getInvalidationId, getInvalidationHash} from './assetUtils';
import {optionsProxy} from './utils';
import {invalidateDevDeps} from './requests/DevDepRequest';
import {tracer, PluginTracer} from '@parcel/profiler';

type Opts = {|
  config: ParcelConfig,
  options: ParcelOptions,
  report: ReportFn,
  previousDevDeps: Map<string, string>,
  previousInvalidations: Array<RequestInvalidation>,
|};

export type RunPackagerRunnerResult = {|
  bundleInfo: BundleInfo[],
  configRequests: Array<ConfigRequest>,
  devDepRequests: Array<DevDepRequest | DevDepRequestRef>,
  invalidations: Array<RequestInvalidation>,
|};

export type BundleInfo = {|
  +type: string,
  +size: number,
  +hash: string,
  +hashReferences: Array<string>,
  +time?: number,
  +cacheKeys: CacheKeyMap,
  +isLargeBlob: boolean,
|};

type BundleContent = {|
  type: string,
  contents: Blob,
  map: ?string,
|};

type CacheKeyMap = {|
  content: string,
  map: string,
|};

const BOUNDARY_LENGTH = HASH_REF_PREFIX.length + 32 - 1;

// Packager/optimizer configs are not bundle-specific, so we only need to
// load them once per build.
const pluginConfigs = createBuildCache();

export default class PackagerRunner {
  config: ParcelConfig;
  options: ParcelOptions;
  pluginOptions: PluginOptions;
  distDir: FilePath;
  distExists: Set<FilePath>;
  report: ReportFn;
  previousDevDeps: Map<string, string>;
  devDepRequests: Map<string, DevDepRequest | DevDepRequestRef>;
  invalidations: Map<string, RequestInvalidation>;
  previousInvalidations: Array<RequestInvalidation>;

  constructor({
    config,
    options,
    report,
    previousDevDeps,
    previousInvalidations,
  }: Opts) {
    this.config = config;
    this.options = options;
    this.report = report;
    this.previousDevDeps = previousDevDeps;
    this.devDepRequests = new Map();
    this.previousInvalidations = previousInvalidations;
    this.invalidations = new Map();
    this.pluginOptions = new PluginOptions(
      optionsProxy(this.options, option => {
        let invalidation: RequestInvalidation = {
          type: 'option',
          key: option,
        };

        this.invalidations.set(getInvalidationId(invalidation), invalidation);
      }),
    );
  }

  async run(
    bundleGraph: InternalBundleGraph,
    bundle: InternalBundle,
    invalidDevDeps: Array<DevDepSpecifier>,
  ): Promise<RunPackagerRunnerResult> {
    invalidateDevDeps(invalidDevDeps, this.options, this.config);

    let {configs, bundleConfigs} = await this.loadConfigs(bundleGraph, bundle);
    let bundleInfo = await this.getBundleInfoFromCache(
      bundleGraph,
      bundle,
      configs,
      bundleConfigs,
    );

    if (bundleInfo.length === 0) {
      bundleInfo = await this.getBundleInfo(
        bundle,
        bundleGraph,
        configs,
        bundleConfigs,
      );
    }

    let configRequests = getConfigRequests([
      ...configs.values(),
      ...bundleConfigs.values(),
    ]);
    let devDepRequests = getWorkerDevDepRequests([
      ...this.devDepRequests.values(),
    ]);

    return {
      bundleInfo,
      configRequests,
      devDepRequests,
      invalidations: [...this.invalidations.values()],
    };
  }

  async loadConfigs(
    bundleGraph: InternalBundleGraph,
    bundle: InternalBundle,
  ): Promise<{|
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
  |}> {
    let configs = new Map();
    let bundleConfigs = new Map();

    await this.loadConfig(bundleGraph, bundle, configs, bundleConfigs);
    for (let inlineBundle of bundleGraph.getInlineBundles(bundle)) {
      await this.loadConfig(bundleGraph, inlineBundle, configs, bundleConfigs);
    }

    return {configs, bundleConfigs};
  }

  async loadConfig(
    bundleGraph: InternalBundleGraph,
    bundle: InternalBundle,
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
  ): Promise<void> {
    let name = nullthrows(bundle.name);
    let plugin = await this.config.getPackager(name, bundle.pipeline);
    await this.loadPluginConfig(
      bundleGraph,
      bundle,
      plugin,
      configs,
      bundleConfigs,
    );

    let optimizers = await this.config.getOptimizers(name, bundle.pipeline);
    for (let optimizer of optimizers) {
      await this.loadPluginConfig(
        bundleGraph,
        bundle,
        optimizer,
        configs,
        bundleConfigs,
      );
    }
  }

  async loadPluginConfig<T: PluginWithBundleConfig>(
    bundleGraph: InternalBundleGraph,
    bundle: InternalBundle,
    plugin: LoadedPlugin<T>,
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
  ): Promise<void> {
    if (!configs.has(plugin.name)) {
      // Only load config for a plugin once per build.
      let existing = pluginConfigs.get(plugin.name);
      if (existing != null) {
        configs.set(plugin.name, existing);
      } else {
        if (plugin.plugin.loadConfig != null) {
          let config = createConfig({
            plugin: plugin.name,
            searchPath: toProjectPathUnsafe('index'),
          });

          await loadPluginConfig(plugin, config, this.options);

          for (let devDep of config.devDeps) {
            let devDepRequest = await createDevDependency(
              devDep,
              this.previousDevDeps,
              this.options,
            );
            let key = `${devDep.specifier}:${fromProjectPath(
              this.options.projectRoot,
              devDep.resolveFrom,
            )}`;
            this.devDepRequests.set(key, devDepRequest);
          }

          pluginConfigs.set(plugin.name, config);
          configs.set(plugin.name, config);
        }
      }
    }

    let loadBundleConfig = plugin.plugin.loadBundleConfig;
    if (!bundleConfigs.has(plugin.name) && loadBundleConfig != null) {
      let config = createConfig({
        plugin: plugin.name,
        searchPath: joinProjectPath(
          bundle.target.distDir,
          bundle.name ?? bundle.id,
        ),
      });
      config.result = await loadBundleConfig({
        bundle: NamedBundle.get(bundle, bundleGraph, this.options),
        bundleGraph: new BundleGraph<NamedBundleType>(
          bundleGraph,
          NamedBundle.get.bind(NamedBundle),
          this.options,
        ),
        config: new PublicConfig(config, this.options),
        options: new PluginOptions(this.options),
        logger: new PluginLogger({origin: plugin.name}),
        tracer: new PluginTracer({origin: plugin.name, category: 'loadConfig'}),
      });
      bundleConfigs.set(plugin.name, config);
    }
  }

  async getBundleInfoFromCache(
    bundleGraph: InternalBundleGraph,
    bundle: InternalBundle,
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
  ): Promise<BundleInfo[]> {
    if (this.options.shouldDisableCache) {
      return [];
    }

    let cacheKey = await this.getCacheKey(
      bundle,
      bundleGraph,
      configs,
      bundleConfigs,
      this.previousInvalidations,
    );
    let infoKey = PackagerRunner.getInfoKey(cacheKey);
    let res = await this.options.cache.get<BundleInfo[]>(infoKey);
    return res ?? [];
  }

  async getBundleInfo(
    bundle: InternalBundle,
    bundleGraph: InternalBundleGraph,
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
  ): Promise<BundleInfo[]> {
    let results = await this.getBundleResult(
      bundle,
      bundleGraph,
      configs,
      bundleConfigs,
    );

    // Recompute cache keys as they may have changed due to dev dependencies.
    let cacheKey = await this.getCacheKey(
      bundle,
      bundleGraph,
      configs,
      bundleConfigs,
      [...this.invalidations.values()],
    );

    return this.writeToCache(cacheKey, results);
  }

  async getInlineBundleContents(
    bundle: InternalBundle,
    bundleGraph: InternalBundleGraph,
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
  ): Promise<Blob> {
    let bundleInfo = await this.getBundleInfoFromCache(
      bundleGraph,
      bundle,
      configs,
      bundleConfigs,
    );

    if (bundleInfo.length > 0) {
      return this.options.cache.getBlob(bundleInfo[0].cacheKeys.content);
    }

    let results = await this.getBundleResult(
      bundle,
      bundleGraph,
      configs,
      bundleConfigs,
    );

    // Writing to the cache consumes the stream, but we need to also return it to the calling packager.
    // Buffer the stream into memory first.
    if (results[0].contents instanceof Readable) {
      results[0].contents = await bufferStream(results[0].contents);
    }

    // Recompute cache keys as they may have changed due to dev dependencies.
    let cacheKey = await this.getCacheKey(
      bundle,
      bundleGraph,
      configs,
      bundleConfigs,
      [...this.invalidations.values()],
    );

    await this.writeToCache(cacheKey, results);
    return results[0].contents;
  }

  async getBundleResult(
    bundle: InternalBundle,
    bundleGraph: InternalBundleGraph,
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
  ): Promise<BundleContent[]> {
    let packagedResults = await this.package(
      bundle,
      bundleGraph,
      configs,
      bundleConfigs,
    );
    return Promise.all(
      packagedResults.map(async packaged => {
        let type = packaged.type ?? bundle.type;
        let res = await this.optimize(
          bundle,
          bundleGraph,
          type,
          packaged.contents,
          packaged.map,
          configs,
          bundleConfigs,
        );

        let map =
          res.map != null
            ? await this.generateSourceMap(bundle, res.map)
            : null;
        return {
          type: res.type ?? type,
          contents: res.contents,
          map,
        };
      }),
    );
  }

  getSourceMapReference(bundle: NamedBundle, map: ?SourceMap): Async<?string> {
    if (map && bundle.env.sourceMap && bundle.bundleBehavior !== 'inline') {
      if (bundle.env.sourceMap && bundle.env.sourceMap.inline) {
        return this.generateSourceMap(bundleToInternalBundle(bundle), map);
      } else {
        return path.basename(bundle.name) + '.map';
      }
    } else {
      return null;
    }
  }

  async package(
    internalBundle: InternalBundle,
    bundleGraph: InternalBundleGraph,
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
  ): Promise<BundleResult[]> {
    let bundle = NamedBundle.get(internalBundle, bundleGraph, this.options);
    this.report({
      type: 'buildProgress',
      phase: 'packaging',
      bundle,
    });

    let packager = await this.config.getPackager(
      bundle.name,
      internalBundle.pipeline,
    );
    let {name, resolveFrom, plugin} = packager;
    let measurement;
    try {
      measurement = tracer.createMeasurement(name, 'packaging', bundle.name, {
        type: bundle.type,
      });
      let res = await plugin.package({
        config: configs.get(name)?.result,
        bundleConfig: bundleConfigs.get(name)?.result,
        bundle,
        bundleGraph: new BundleGraph<NamedBundleType>(
          bundleGraph,
          NamedBundle.get.bind(NamedBundle),
          this.options,
        ),
        getSourceMapReference: map => {
          return this.getSourceMapReference(bundle, map);
        },
        options: this.pluginOptions,
        logger: new PluginLogger({origin: name}),
        tracer: new PluginTracer({origin: name, category: 'package'}),
        getInlineBundleContents: async (
          bundle: BundleType,
          bundleGraph: BundleGraphType<NamedBundleType>,
        ) => {
          if (bundle.bundleBehavior !== 'inline') {
            throw new Error(
              'Bundle is not inline and unable to retrieve contents',
            );
          }

          let contents = await this.getInlineBundleContents(
            bundleToInternalBundle(bundle),
            // $FlowFixMe
            bundleGraphToInternalBundleGraph(bundleGraph),
            configs,
            bundleConfigs,
          );
          return {contents};
        },
      });
      if (Array.isArray(res)) {
        return res;
      }
      return [res];
    } catch (e) {
      throw new ThrowableDiagnostic({
        diagnostic: errorToDiagnostic(e, {
          origin: name,
          filePath: path.join(bundle.target.distDir, bundle.name),
        }),
      });
    } finally {
      measurement && measurement.end();
      // Add dev dependency for the packager. This must be done AFTER running it due to
      // the potential for lazy require() that aren't executed until the request runs.
      let devDepRequest = await createDevDependency(
        {
          specifier: name,
          resolveFrom,
        },
        this.previousDevDeps,
        this.options,
      );
      this.devDepRequests.set(
        `${name}:${fromProjectPathRelative(resolveFrom)}`,
        devDepRequest,
      );
    }
  }

  async optimize(
    internalBundle: InternalBundle,
    internalBundleGraph: InternalBundleGraph,
    type: string,
    contents: Blob,
    map?: ?SourceMap,
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
  ): Promise<BundleResult> {
    let bundle = NamedBundle.get(
      internalBundle,
      internalBundleGraph,
      this.options,
    );
    let bundleGraph = new BundleGraph<NamedBundleType>(
      internalBundleGraph,
      NamedBundle.get.bind(NamedBundle),
      this.options,
    );
    let name = bundle.name;
    if (type !== bundle.type) {
      name = name.slice(0, -path.extname(name).length) + '.' + type;
    }
    let optimizers = await this.config.getOptimizers(
      name,
      internalBundle.pipeline,
    );
    if (!optimizers.length) {
      return {type, contents, map};
    }

    this.report({
      type: 'buildProgress',
      phase: 'optimizing',
      bundle,
    });

    let optimized = {
      type,
      contents,
      map,
    };

    for (let optimizer of optimizers) {
      let measurement;
      try {
        measurement = tracer.createMeasurement(
          optimizer.name,
          'optimize',
          bundle.name,
        );
        let next = await optimizer.plugin.optimize({
          config: configs.get(optimizer.name)?.result,
          bundleConfig: bundleConfigs.get(optimizer.name)?.result,
          bundle,
          bundleGraph,
          contents: optimized.contents,
          map: optimized.map,
          getSourceMapReference: map => {
            return this.getSourceMapReference(bundle, map);
          },
          options: this.pluginOptions,
          logger: new PluginLogger({origin: optimizer.name}),
          tracer: new PluginTracer({
            origin: optimizer.name,
            category: 'optimize',
          }),
        });

        optimized.type = next.type ?? optimized.type;
        optimized.contents = next.contents;
        optimized.map = next.map;
      } catch (e) {
        throw new ThrowableDiagnostic({
          diagnostic: errorToDiagnostic(e, {
            origin: optimizer.name,
            filePath: path.join(bundle.target.distDir, bundle.name),
          }),
        });
      } finally {
        measurement && measurement.end();
        // Add dev dependency for the optimizer. This must be done AFTER running it due to
        // the potential for lazy require() that aren't executed until the request runs.
        let devDepRequest = await createDevDependency(
          {
            specifier: optimizer.name,
            resolveFrom: optimizer.resolveFrom,
          },
          this.previousDevDeps,
          this.options,
        );
        this.devDepRequests.set(
          `${optimizer.name}:${fromProjectPathRelative(optimizer.resolveFrom)}`,
          devDepRequest,
        );
      }
    }

    return optimized;
  }

  async generateSourceMap(
    bundle: InternalBundle,
    map: SourceMap,
  ): Promise<string> {
    // sourceRoot should be a relative path between outDir and rootDir for node.js targets
    let filePath = joinProjectPath(
      bundle.target.distDir,
      nullthrows(bundle.name),
    );
    let fullPath = fromProjectPath(this.options.projectRoot, filePath);
    let sourceRoot: string = path.relative(
      path.dirname(fullPath),
      this.options.projectRoot,
    );
    let inlineSources = false;

    if (bundle.target) {
      if (
        bundle.env.sourceMap &&
        bundle.env.sourceMap.sourceRoot !== undefined
      ) {
        sourceRoot = bundle.env.sourceMap.sourceRoot;
      } else if (
        this.options.serveOptions &&
        bundle.target.env.context === 'browser'
      ) {
        sourceRoot = '/__parcel_source_root';
      }

      if (
        bundle.env.sourceMap &&
        bundle.env.sourceMap.inlineSources !== undefined
      ) {
        inlineSources = bundle.env.sourceMap.inlineSources;
      } else if (bundle.target.env.context !== 'node') {
        // inlining should only happen in production for browser targets by default
        inlineSources = this.options.mode === 'production';
      }
    }

    let mapFilename = fullPath + '.map';
    let isInlineMap = bundle.env.sourceMap && bundle.env.sourceMap.inline;

    let stringified = await map.stringify({
      file: path.basename(mapFilename),
      // $FlowFixMe
      fs: this.options.inputFS,
      rootDir: this.options.projectRoot,
      sourceRoot: !inlineSources
        ? url.format(url.parse(sourceRoot + '/'))
        : undefined,
      inlineSources,
      format: isInlineMap ? 'inline' : 'string',
    });

    invariant(typeof stringified === 'string');
    return stringified;
  }

  async getCacheKey(
    bundle: InternalBundle,
    bundleGraph: InternalBundleGraph,
    configs: Map<string, Config>,
    bundleConfigs: Map<string, Config>,
    invalidations: Array<RequestInvalidation>,
  ): Promise<string> {
    let configResults = {};
    for (let [pluginName, config] of configs) {
      if (config) {
        configResults[pluginName] = await getConfigHash(
          config,
          pluginName,
          this.options,
        );
      }
    }
    let globalInfoResults = {};
    for (let [pluginName, config] of bundleConfigs) {
      if (config) {
        globalInfoResults[pluginName] = await getConfigHash(
          config,
          pluginName,
          this.options,
        );
      }
    }

    let devDepHashes = await this.getDevDepHashes(bundle);
    for (let inlineBundle of bundleGraph.getInlineBundles(bundle)) {
      devDepHashes += await this.getDevDepHashes(inlineBundle);
    }

    let invalidationHash = await getInvalidationHash(
      invalidations,
      this.options,
    );

    return hashString(
      PARCEL_VERSION +
        devDepHashes +
        invalidationHash +
        bundle.target.publicUrl +
        bundleGraph.getHash(bundle) +
        JSON.stringify(configResults) +
        JSON.stringify(globalInfoResults) +
        this.options.mode +
        (this.options.shouldBuildLazily ? 'lazy' : 'eager'),
    );
  }

  async getDevDepHashes(bundle: InternalBundle): Promise<string> {
    let name = nullthrows(bundle.name);
    let packager = await this.config.getPackager(name, bundle.pipeline);
    let optimizers = await this.config.getOptimizers(name, bundle.pipeline);

    let key = `${packager.name}:${fromProjectPathRelative(
      packager.resolveFrom,
    )}`;
    let devDepHashes =
      this.devDepRequests.get(key)?.hash ?? this.previousDevDeps.get(key) ?? '';
    for (let {name, resolveFrom} of optimizers) {
      let key = `${name}:${fromProjectPathRelative(resolveFrom)}`;
      devDepHashes +=
        this.devDepRequests.get(key)?.hash ??
        this.previousDevDeps.get(key) ??
        '';
    }

    return devDepHashes;
  }

  async writeToCache(
    cacheKey: string,
    results: Array<{|
      contents: Blob,
      map: ?string,
      type: string,
    |}>,
  ): Promise<BundleInfo[]> {
    let info = await Promise.all(
      results.map(async ({contents, map, type}, index) => {
        let size = 0;
        let hash;
        let hashReferences = [];
        let isLargeBlob = false;
        let cacheKeys = {
          content: PackagerRunner.getContentKey(cacheKey, index),
          map: PackagerRunner.getMapKey(cacheKey, index),
        };

        // TODO: don't replace hash references in binary files??
        if (contents instanceof Readable) {
          isLargeBlob = true;
          let boundaryStr = '';
          let h = new Hash();
          await this.options.cache.setStream(
            cacheKeys.content,
            blobToStream(contents).pipe(
              new TapStream(buf => {
                let str = boundaryStr + buf.toString();
                hashReferences = hashReferences.concat(
                  str.match(HASH_REF_REGEX) ?? [],
                );
                size += buf.length;
                h.writeBuffer(buf);
                boundaryStr = str.slice(str.length - BOUNDARY_LENGTH);
              }),
            ),
          );
          hash = h.finish();
        } else if (typeof contents === 'string') {
          let buffer = Buffer.from(contents);
          size = buffer.byteLength;
          hash = hashBuffer(buffer);
          hashReferences = contents.match(HASH_REF_REGEX) ?? [];
          await this.options.cache.setBlob(cacheKeys.content, buffer);
        } else {
          size = contents.length;
          hash = hashBuffer(contents);
          hashReferences = contents.toString().match(HASH_REF_REGEX) ?? [];
          await this.options.cache.setBlob(cacheKeys.content, contents);
        }

        if (map != null) {
          await this.options.cache.setBlob(cacheKeys.map, map);
        }
        let info: BundleInfo = {
          type,
          size,
          hash,
          hashReferences,
          cacheKeys,
          isLargeBlob,
        };
        return info;
      }),
    );
    await this.options.cache.set(PackagerRunner.getInfoKey(cacheKey), info);
    return info;
  }

  static getContentKey(cacheKey: string, index: number): string {
    return hashString(`${cacheKey}:${index}:content`);
  }

  static getMapKey(cacheKey: string, index: number): string {
    return hashString(`${cacheKey}:${index}:map`);
  }

  static getInfoKey(cacheKey: string): string {
    return hashString(`${cacheKey}:info`);
  }
}
