const _ = require('lodash');
const postcss = require('postcss');
const assetGraphConditions = require('../assetGraphConditions');
const combineJavaScriptAsts = require('../combineJavaScriptAsts');
const createConditionalCommentHousekeeper = require('../createConditionalCommentHousekeeper');

module.exports = (
  queryObj,
  { strategyName = 'oneBundlePerIncludingAsset' } = {}
) => {
  return function bundleRelations(assetGraph) {
    function getDiscriminatorForRelation(relation, getOpenConditionalComments) {
      const discriminatorFragments = [];
      discriminatorFragments.push(relation.type); // HtmlScript vs. JavaScriptImportScripts
      if (relation.to.isLoaded) {
        discriminatorFragments.push('isLoaded');
      }
      let isInsideHead = false;
      let parentNode = relation.node.parentNode;
      while (parentNode) {
        if (parentNode.nodeName.toLowerCase() === 'head') {
          isInsideHead = true;
          break;
        }
        parentNode = parentNode.parentNode;
      }
      if (isInsideHead) {
        discriminatorFragments.push('head');
      } else {
        discriminatorFragments.push('body');
      }
      if (relation.from.type === 'Html') {
        discriminatorFragments.push(...getOpenConditionalComments(relation));
      }
      if (
        relation.node &&
        relation.node.hasAttribute &&
        relation.node.hasAttribute('bundle')
      ) {
        discriminatorFragments.push(relation.node.getAttribute('bundle'));
      }
      if (relation.type === 'HtmlStyle') {
        discriminatorFragments.push(
          relation.node.getAttribute('media') || 'all'
        );
        for (const attribute of Array.from(relation.node.attributes)) {
          if (
            attribute.name !== 'charset' &&
            attribute.name !== 'media' &&
            attribute.name !== 'bundle' &&
            attribute.name !== 'nonce' && // CSP
            attribute.name !== 'data-assetgraph-conditions' &&
            (attribute.name !== 'rel' || attribute.value !== 'stylesheet') &&
            (attribute.name !== 'href' ||
              relation.node.nodeName.toLowerCase() !== 'link') &&
            (attribute.name !== 'type' ||
              attribute.value !== 'text/css' ||
              !relation.to ||
              relation.to.type !== 'Css')
          ) {
            return 'nobundle';
          }
        }
      } else if (relation.type === 'HtmlScript') {
        if (relation.to.isLoaded && relation.to.isStrict) {
          discriminatorFragments.push('strict');
          const warning = new Error(
            'Global "use strict"-directive. Splitting into multiple bundles to avoid side effects.'
          );
          warning.asset = relation.to;
          assetGraph.info(warning);
        }
        if (relation.node.getAttribute('defer') === 'defer') {
          discriminatorFragments.push('defer');
        }
        if (relation.node.getAttribute('async') === 'async') {
          discriminatorFragments.push('async');
        }
        for (const attribute of Array.from(relation.node.attributes)) {
          if (
            attribute.name !== 'charset' &&
            attribute.name !== 'src' &&
            attribute.name !== 'bundle' &&
            attribute.name !== 'nonce' && // CSP
            attribute.name !== 'data-assetgraph-conditions' &&
            (attribute.name !== 'defer' || attribute.value !== 'defer') &&
            (attribute.name !== 'async' || attribute.value !== 'async') &&
            (attribute.name !== 'type' ||
              attribute.value !== 'text/javascript' ||
              !relation.to ||
              relation.to.type !== 'JavaScript')
          ) {
            return 'nobundle';
          }
        }
      }
      return discriminatorFragments.join(':');
    }

    // Reuses the parse trees of existing assets, so be careful!
    function makeBundle(assetsToBundle, incomingType) {
      if (assetsToBundle.length === 0) {
        throw new Error('makeBundle: Bundle must contain at least one asset');
      } else if (assetsToBundle.length === 1) {
        // Shortcut
        return [assetsToBundle[0]];
      }

      const type = assetsToBundle[0].type;

      let parseTree;
      if (type === 'JavaScript') {
        parseTree = combineJavaScriptAsts(
          assetsToBundle.map((asset) => asset.parseTree)
        );
      } else {
        // type === 'Css'
        parseTree = postcss.parse('');
        // Make sure that all @import rules go at the top of the bundle:
        const importRules = [];
        for (const asset of assetsToBundle) {
          const topLevelNodes = asset.parseTree.nodes;
          for (let i = 0; i < topLevelNodes.length; i += 1) {
            const topLevelNode = topLevelNodes[i];
            topLevelNode.parent = parseTree;
            if (
              topLevelNode.type === 'atrule' &&
              topLevelNode.name === 'import'
            ) {
              importRules.push(topLevelNode);
              topLevelNodes.splice(i, 1);
              i -= 1;
            }
          }
          parseTree.nodes.push(...topLevelNodes);
        }
        if (importRules.length > 0) {
          parseTree.nodes.unshift(...importRules);
        }
      }

      const bundleAsset = assetGraph.addAsset({
        type,
        parseTree,
        baseName: 'bundle',
        outgoingRelations: assetGraph.findRelations({
          from: { id: { $in: assetsToBundle.map((asset) => asset.id) } },
        }),
        lastKnownByteLength: assetsToBundle.reduce(
          (sumOfLastKnownByteLengths, asset) => {
            return sumOfLastKnownByteLengths + asset.lastKnownByteLength;
          },
          0
        ),
        _toBeMinified: assetsToBundle.every((asset) => asset._toBeMinified),
        isPretty: assetsToBundle.every((asset) => asset.isPretty),
      });

      const seenReferringAssets = new Set();
      const incomingRelations = assetGraph.findRelations({
        type: incomingType,
        to: { id: { $in: assetsToBundle.map((asset) => asset.id) } },
      });

      // Point at the bundled asset with a root-relative href if at least one of the relations
      // being bundled have a more specific hrefType than 'relative':
      const bundleRelationHrefType = incomingRelations.some(
        (incomingRelation) =>
          !['relative', 'inline'].includes(incomingRelation.hrefType)
      )
        ? 'rootRelative'
        : 'relative';

      const combinedAssetGraphConditions = {};
      // Reverse iteration for HtmlScript relations to ensure bundle insertion at tail end
      for (const incomingRelation of incomingType === 'HtmlScript'
        ? incomingRelations.slice().reverse()
        : incomingRelations) {
        if (!seenReferringAssets.has(incomingRelation.from)) {
          const bundleRelation = incomingRelation.from.addRelation(
            {
              type: incomingType,
              hrefType: bundleRelationHrefType,
              media: incomingRelation.media, // Only used for HtmlStyle
              async: incomingRelation.async, // Only used for HtmlScript
              defer: incomingRelation.defer, // Only used for HtmlScript
              to: bundleAsset,
            },
            'before',
            incomingRelation
          );
          if (incomingRelation.from.type === 'Html') {
            let commonNonce;
            let nonceIsUnique = true;
            for (const relation of incomingRelations) {
              if (relation.from === incomingRelation.from) {
                if (relation.node.hasAttribute('nonce')) {
                  const nonce = relation.node.getAttribute('nonce');
                  if (typeof commonNonce === 'undefined') {
                    commonNonce = nonce;
                  } else if (commonNonce !== nonce) {
                    nonceIsUnique = false;
                  }
                }
              }
              const conditions = assetGraphConditions.parse(relation.node);
              if (conditions) {
                for (const conditionName of Object.keys(conditions)) {
                  if (
                    Array.isArray(combinedAssetGraphConditions[conditionName])
                  ) {
                    combinedAssetGraphConditions[conditionName].push(
                      conditions[conditionName]
                    );
                  } else {
                    combinedAssetGraphConditions[conditionName] = [
                      conditions[conditionName],
                    ];
                  }
                }
              }
            }
            if (typeof commonNonce !== 'undefined' && nonceIsUnique) {
              bundleRelation.node.setAttribute('nonce', commonNonce);
              bundleRelation.from.markDirty();
            }
            const conditionNames = Object.keys(combinedAssetGraphConditions);
            if (conditionNames.length > 0) {
              for (const conditionName of conditionNames) {
                const uniqueValues = _.uniq(
                  combinedAssetGraphConditions[conditionName]
                );
                if (uniqueValues.length === 1) {
                  combinedAssetGraphConditions[conditionName] = uniqueValues[0];
                } else {
                  combinedAssetGraphConditions[conditionName] = uniqueValues;
                }
              }
              bundleRelation.node.setAttribute(
                'data-assetgraph-conditions',
                assetGraphConditions.stringify(combinedAssetGraphConditions)
              );
            }
          }
          seenReferringAssets.add(incomingRelation.from);
        }
        incomingRelation.detach();
      }

      for (const outgoingRelation of assetGraph.findRelations({
        from: bundleAsset,
      })) {
        outgoingRelation.refreshHref();
      }

      for (const asset of assetsToBundle) {
        if (
          assetGraph.findRelations({
            to: asset,
            type: { $not: 'SourceMapFile' },
          }).length === 0
        ) {
          for (const sourceMapFileRelation of assetGraph.findRelations({
            to: asset,
            type: 'SourceMapFile',
          })) {
            sourceMapFileRelation.remove();
          }
          assetGraph.removeAsset(asset);
        }
      }
      return bundleAsset;
    }

    const bundleStrategyByName = {};

    // Quick and dirty bundling strategy that gets you down to one <script> and one <link rel='stylesheet'>
    // per document, but doesn't do any cross-page optimization.
    bundleStrategyByName.oneBundlePerIncludingAsset = () => {
      const getOpenConditionalComments = createConditionalCommentHousekeeper();
      const bundleAssets = [];
      const relationsByIncludingAsset = {};

      for (const relation of assetGraph.findRelations(queryObj)) {
        (relationsByIncludingAsset[relation.from.id] =
          relationsByIncludingAsset[relation.from.id] || []).push(relation);
      }

      for (const includingAssetId of Object.keys(relationsByIncludingAsset)) {
        const relationsToBundle = relationsByIncludingAsset[includingAssetId];

        for (const relationType of _.uniq(
          relationsToBundle.map((r) => r.type)
        )) {
          let currentBundle = [];
          let bundleDiscriminator;
          const relationsOfTypeToBundle = relationsToBundle.filter(
            (relation) => relation.type === relationType
          );

          /* eslint-disable no-inner-declarations */
          function flushBundle() {
            if (currentBundle.length > 0) {
              bundleAssets.push(makeBundle(currentBundle, relationType));
              currentBundle = [];
            }
          }
          for (const outgoingRelation of assetGraph.findRelations({
            type: { $in: [relationType, 'HtmlConditionalComment'] },
          })) {
            if (outgoingRelation.type === 'HtmlConditionalComment') {
              if (
                assetGraph.findRelations({
                  from: outgoingRelation.to,
                  type: relationType,
                }).length > 0
              ) {
                flushBundle();
              }
            } else if (relationsOfTypeToBundle.includes(outgoingRelation)) {
              // Make sure that we don't bundle HtmlStyles with different media attributes together etc.:
              const discriminator = getDiscriminatorForRelation(
                outgoingRelation,
                getOpenConditionalComments
              );
              if (
                bundleDiscriminator &&
                (discriminator === 'nobundle' ||
                  bundleDiscriminator === 'nobundle' ||
                  discriminator !== bundleDiscriminator)
              ) {
                flushBundle();
              }
              bundleDiscriminator = discriminator;
              if (
                assetGraph.findRelations({
                  to: outgoingRelation.to,
                  type: { $not: 'SourceMapFile' },
                }).length > 1
              ) {
                currentBundle.push(outgoingRelation.to.clone(outgoingRelation));
              } else {
                currentBundle.push(outgoingRelation.to);
              }
            } else {
              flushBundle();
            }
          }
          flushBundle();
        }
      }
      return bundleAssets;
    };

    // Cross-page optimizing bundling strategy that never puts the same chunk in multiple bundles, but still tries
    // to create as few bundles as possible. Also preserves inclusion order.
    bundleStrategyByName.sharedBundles = () => {
      const getOpenConditionalComments = createConditionalCommentHousekeeper();
      const assetIndex = {};
      const seenIncludingAssets = {};
      const bundles = [];
      const relationsByIncludingAsset = {};

      for (const relation of assetGraph.findRelations(queryObj)) {
        assetIndex[relation.to.id] = null; // Means not in a bundle yet
        seenIncludingAssets[relation.from.id] = relation.from;
        (relationsByIncludingAsset[relation.from.id] =
          relationsByIncludingAsset[relation.from.id] || []).push(relation);
      }

      function splitBundle(bundle, index) {
        const newBundle = bundle.splice(index);
        newBundle._relationType = bundle._relationType;
        for (const asset of newBundle) {
          assetIndex[asset.id] = newBundle;
        }
        if (newBundle.length > 0) {
          bundles.push(newBundle);
        }
        return newBundle;
      }

      for (const includingAsset of _.values(seenIncludingAssets)) {
        const relationsToBundle = relationsByIncludingAsset[includingAsset.id];

        for (const relationType of _.uniq(
          relationsToBundle.map((r) => r.type)
        )) {
          const outgoingRelations = assetGraph.findRelations({
            from: includingAsset,
            type: { $in: [relationType, 'HtmlConditionalComment'] },
          });
          let previousBundle;
          let bundleDiscriminator;
          let canAppendToPreviousBundle = false;
          let previousBundleIndex;

          for (const outgoingRelation of outgoingRelations) {
            if (outgoingRelation.type === 'HtmlConditionalComment') {
              if (
                assetGraph.findRelations({
                  from: outgoingRelation.to,
                  type: relationType,
                }).length > 0
              ) {
                canAppendToPreviousBundle = false;
              }
              continue;
            }

            // Make sure that we don't bundle HtmlStyles with different media attributes together etc.:
            const discriminator = getDiscriminatorForRelation(
              outgoingRelation,
              getOpenConditionalComments
            );
            if (
              bundleDiscriminator &&
              (discriminator === 'nobundle' ||
                bundleDiscriminator === 'nobundle' ||
                discriminator !== bundleDiscriminator)
            ) {
              canAppendToPreviousBundle = false;
            }
            bundleDiscriminator = discriminator;

            let existingBundle = assetIndex[outgoingRelation.to.id];
            if (existingBundle === null) {
              // Not bundled yet, append to previousBundle if possible, else create a new one
              if (canAppendToPreviousBundle) {
                previousBundle.push(outgoingRelation.to);
                previousBundleIndex = previousBundle.length - 1;
              } else {
                if (
                  previousBundle &&
                  previousBundleIndex !== previousBundle.length - 1
                ) {
                  splitBundle(previousBundle, previousBundleIndex + 1);
                }
                previousBundle = [outgoingRelation.to];
                previousBundle._relationType = relationType;
                previousBundleIndex = 0;
                bundles.push(previousBundle);
                canAppendToPreviousBundle = true;
              }
              assetIndex[outgoingRelation.to.id] = previousBundle;
            } else if (existingBundle) {
              // Already in another bundle
              canAppendToPreviousBundle = false;
              let indexInExistingBundle = existingBundle.indexOf(
                outgoingRelation.to
              );
              if (previousBundle && existingBundle === previousBundle) {
                if (indexInExistingBundle === previousBundleIndex + 1) {
                  previousBundleIndex = indexInExistingBundle;
                } else {
                  splitBundle(previousBundle, indexInExistingBundle + 1);
                  existingBundle = assetIndex[outgoingRelation.to.id];
                  indexInExistingBundle = existingBundle.indexOf(
                    outgoingRelation.to
                  );
                  if (indexInExistingBundle !== 0) {
                    existingBundle = splitBundle(
                      existingBundle,
                      indexInExistingBundle
                    );
                  }
                  previousBundle = existingBundle;
                  previousBundleIndex = 0;
                }
              } else {
                if (
                  previousBundle &&
                  previousBundleIndex !== previousBundle.length - 1
                ) {
                  splitBundle(previousBundle, previousBundleIndex + 1);
                }
                if (indexInExistingBundle !== 0) {
                  existingBundle = splitBundle(
                    existingBundle,
                    indexInExistingBundle
                  );
                }
                previousBundle = existingBundle;
                previousBundleIndex = 0;
              }
            } else {
              // The relation doesn't point at an asset matched by queryObj
              previousBundle = null;
              canAppendToPreviousBundle = false;
            }
          }
          // No more outgoing relations for this asset, make sure that the asset that was bundled
          // last is at the last position in its bundle:
          if (
            previousBundle &&
            previousBundleIndex !== previousBundle.length - 1
          ) {
            splitBundle(previousBundle, previousBundleIndex + 1);
          }
        }
      }

      return bundles.map((bundle) => makeBundle(bundle, bundle._relationType));
    };

    for (const bundleAsset of bundleStrategyByName[strategyName]()) {
      for (const incomingRelation of assetGraph.findRelations({
        to: bundleAsset,
      })) {
        incomingRelation.refreshHref();
      }
      for (const outgoingRelation of assetGraph.findRelations({
        from: bundleAsset,
      })) {
        outgoingRelation.refreshHref();
      }
    }
  };
};
