// @flow
import type {
  DependencySpecifier,
  SemverRange,
  Invalidations,
} from '@parcel/types';
import type ParcelConfig from '../ParcelConfig';
import type {
  DevDepRequest,
  DevDepRequestRef,
  ParcelOptions,
  InternalDevDepOptions,
} from '../types';
import type {RequestResult, RunAPI} from '../RequestTracker';
import type {ProjectPath} from '../projectPath';

import nullthrows from 'nullthrows';
import {getInvalidationHash} from '../assetUtils';
import {createBuildCache} from '../buildCache';
import {invalidateOnFileCreateToInternal} from '../utils';
import {
  fromProjectPath,
  fromProjectPathRelative,
  toProjectPath,
} from '../projectPath';
import {requestTypes} from '../RequestTracker';

// A cache of dev dep requests keyed by invalidations.
// If the package manager returns the same invalidation object, then
// we can reuse the dev dep request rather than recomputing the project
// paths and hashes.
const devDepRequestCache: WeakMap<Invalidations, DevDepRequest> = new WeakMap();

export async function createDevDependency(
  opts: InternalDevDepOptions,
  requestDevDeps: Map<string, string>,
  options: ParcelOptions,
): Promise<DevDepRequest | DevDepRequestRef> {
  let {specifier, resolveFrom, additionalInvalidations} = opts;
  let key = `${specifier}:${fromProjectPathRelative(resolveFrom)}`;

  // If the request sent us a hash, we know the dev dep and all of its dependencies didn't change.
  // Reuse the same hash in the response. No need to send back invalidations as the request won't
  // be re-run anyway.
  let hash = requestDevDeps.get(key);
  if (hash != null) {
    return {
      type: 'ref',
      specifier,
      resolveFrom,
      hash,
    };
  }

  let resolveFromAbsolute = fromProjectPath(options.projectRoot, resolveFrom);

  // Ensure that the package manager has an entry for this resolution.
  try {
    await options.packageManager.resolve(specifier, resolveFromAbsolute);
  } catch (err) {
    // ignore
  }

  let invalidations = options.packageManager.getInvalidations(
    specifier,
    resolveFromAbsolute,
  );

  let cached = devDepRequestCache.get(invalidations);
  if (cached != null) {
    return cached;
  }

  let invalidateOnFileChangeProject = [
    ...invalidations.invalidateOnFileChange,
  ].map(f => toProjectPath(options.projectRoot, f));

  // It is possible for a transformer to have multiple different hashes due to
  // different dependencies (e.g. conditional requires) so we must always
  // recompute the hash and compare rather than only sending a transformer
  // dev dependency once.
  hash = await getInvalidationHash(
    invalidateOnFileChangeProject.map(f => ({
      type: 'file',
      filePath: f,
    })),
    options,
  );

  let devDepRequest: DevDepRequest = {
    specifier,
    resolveFrom,
    hash,
    invalidateOnFileCreate: invalidations.invalidateOnFileCreate.map(i =>
      invalidateOnFileCreateToInternal(options.projectRoot, i),
    ),
    invalidateOnFileChange: new Set(invalidateOnFileChangeProject),
    invalidateOnStartup: invalidations.invalidateOnStartup,
    additionalInvalidations,
  };

  devDepRequestCache.set(invalidations, devDepRequest);
  return devDepRequest;
}

export type DevDepSpecifier = {|
  specifier: DependencySpecifier,
  resolveFrom: ProjectPath,
|};

type DevDepRequests = {|
  devDeps: Map<string, string>,
  invalidDevDeps: Array<DevDepSpecifier>,
|};

export async function getDevDepRequests<TResult: RequestResult>(
  api: RunAPI<TResult>,
): Promise<DevDepRequests> {
  let previousDevDepRequests: Map<string, DevDepRequestResult> = new Map(
    await Promise.all(
      api
        .getSubRequests()
        .filter(req => req.requestType === requestTypes.dev_dep_request)
        .map(async req => [
          req.id,
          nullthrows(await api.getRequestResult<DevDepRequestResult>(req.id)),
        ]),
    ),
  );

  return {
    devDeps: new Map(
      [...previousDevDepRequests.entries()]
        .filter(([id]) => api.canSkipSubrequest(id))
        .map(([, req]: [string, DevDepRequestResult]) => [
          `${req.specifier}:${fromProjectPathRelative(req.resolveFrom)}`,
          req.hash,
        ]),
    ),
    invalidDevDeps: await Promise.all(
      [...previousDevDepRequests.entries()]
        .filter(([id]) => !api.canSkipSubrequest(id))
        .flatMap(([, req]: [string, DevDepRequestResult]) => {
          return [
            {
              specifier: req.specifier,
              resolveFrom: req.resolveFrom,
            },
            ...(req.additionalInvalidations ?? []).map(i => ({
              specifier: i.specifier,
              resolveFrom: i.resolveFrom,
            })),
          ];
        }),
    ),
  };
}

// Tracks dev deps that have been invalidated during this build
// so we don't invalidate the require cache more than once.
const invalidatedDevDeps = createBuildCache();

export function invalidateDevDeps(
  invalidDevDeps: Array<DevDepSpecifier>,
  options: ParcelOptions,
  config: ParcelConfig,
) {
  for (let {specifier, resolveFrom} of invalidDevDeps) {
    let key = `${specifier}:${fromProjectPathRelative(resolveFrom)}`;
    if (!invalidatedDevDeps.has(key)) {
      config.invalidatePlugin(specifier);
      options.packageManager.invalidate(
        specifier,
        fromProjectPath(options.projectRoot, resolveFrom),
      );
      invalidatedDevDeps.set(key, true);
    }
  }
}

export type DevDepRequestResult = {|
  specifier: DependencySpecifier,
  resolveFrom: ProjectPath,
  hash: string,
  additionalInvalidations: void | Array<{|
    range?: ?SemverRange,
    resolveFrom: ProjectPath,
    specifier: DependencySpecifier,
  |}>,
|};

export async function runDevDepRequest<TResult: RequestResult>(
  api: RunAPI<TResult>,
  devDepRequestRef: DevDepRequest | DevDepRequestRef,
) {
  await api.runRequest<null, DevDepRequestResult | void>({
    id:
      'dev_dep_request:' +
      devDepRequestRef.specifier +
      ':' +
      devDepRequestRef.hash,
    type: requestTypes.dev_dep_request,
    run: ({api}) => {
      let devDepRequest = resolveDevDepRequestRef(devDepRequestRef);
      for (let filePath of nullthrows(
        devDepRequest.invalidateOnFileChange,
        'DevDepRequest missing invalidateOnFileChange',
      )) {
        api.invalidateOnFileUpdate(filePath);
        api.invalidateOnFileDelete(filePath);
      }

      for (let invalidation of nullthrows(
        devDepRequest.invalidateOnFileCreate,
        'DevDepRequest missing invalidateOnFileCreate',
      )) {
        api.invalidateOnFileCreate(invalidation);
      }

      if (devDepRequest.invalidateOnStartup) {
        api.invalidateOnStartup();
      }

      api.storeResult({
        specifier: devDepRequest.specifier,
        resolveFrom: devDepRequest.resolveFrom,
        hash: devDepRequest.hash,
        additionalInvalidations: devDepRequest.additionalInvalidations,
      });
    },
    input: null,
  });
}

const devDepRequests: Map<string, DevDepRequest> = createBuildCache();
export function resolveDevDepRequestRef(
  devDepRequestRef: DevDepRequest | DevDepRequestRef,
): DevDepRequest {
  const devDepRequest =
    devDepRequestRef.type === 'ref'
      ? devDepRequests.get(devDepRequestRef.hash)
      : devDepRequestRef;
  if (devDepRequest == null) {
    throw new Error(
      `Worker send back a reference to a missing dev dep request.
This might happen due to internal in-memory build caches not being cleared
between builds or due a race condition.
This is a bug in Parcel.`,
    );
  }

  if (devDepRequestRef.type !== 'ref') {
    devDepRequests.set(devDepRequest.hash, devDepRequest);
  }

  return devDepRequest;
}

// A cache of plugin dependency hashes that we've already sent to the main thread.
// Automatically cleared before each build.
const pluginCache = createBuildCache();

export function getWorkerDevDepRequests(
  devDepRequests: Array<DevDepRequest | DevDepRequestRef>,
): Array<DevDepRequest | DevDepRequestRef> {
  return devDepRequests.map(devDepRequest => {
    // If we've already sent a matching transformer + hash to the main thread during this build,
    // there's no need to repeat ourselves.
    let {specifier, resolveFrom, hash} = devDepRequest;
    if (hash === pluginCache.get(specifier)) {
      return {type: 'ref', specifier, resolveFrom, hash};
    } else {
      pluginCache.set(specifier, hash);
      return devDepRequest;
    }
  });
}
