'use strict';
const { dirname } = require('path');
const browserslist = require('browserslist');
const { sameParent } = require('cssnano-utils');
const {
  ensureCompatibility,
  sameVendor,
  noVendor,
} = require('./lib/ensureCompatibility');

/**
 * @param {import('postcss').Declaration} a
 * @param {import('postcss').Declaration} b
 * @return {boolean}
 */
function declarationIsEqual(a, b) {
  return (
    a.important === b.important && a.prop === b.prop && a.value === b.value
  );
}

/**
 * @param {import('postcss').Declaration[]} array
 * @param {import('postcss').Declaration} decl
 * @return {number}
 */
function indexOfDeclaration(array, decl) {
  return array.findIndex((d) => declarationIsEqual(d, decl));
}

/**
 * Returns filtered array of matched or unmatched declarations
 * @param {import('postcss').Declaration[]} a
 * @param {import('postcss').Declaration[]} b
 * @param {boolean} [not=false]
 * @return {import('postcss').Declaration[]}
 */
function intersect(a, b, not) {
  return a.filter((c) => {
    const index = indexOfDeclaration(b, c) !== -1;
    return not ? !index : index;
  });
}

/**
 * @param {import('postcss').Declaration[]} a
 * @param {import('postcss').Declaration[]} b
 * @return {boolean}
 */
function sameDeclarationsAndOrder(a, b) {
  if (a.length !== b.length) {
    return false;
  }
  return a.every((d, index) => declarationIsEqual(d, b[index]));
}

/**
 * RuleMeta stores metadata about a `Rule` during the merging process.
 * It tracks selectors and declarations without re-parsing the AST many times.
 *
 * @typedef {Object} RuleMeta
 * @property {string[]} selectors - Array of selector strings for the rule
 * @property {import('postcss').Declaration[]} declarations - Array of declaration nodes for the rule
 * @property {boolean} dirty - Whether the selectors have been modified and need flushing
 */

/**
 * @param {import('postcss').Rule} ruleA
 * @param {import('postcss').Rule} ruleB
 * @param {string[]} browsers
 * @param {Map<string, boolean>} compatibilityCache
 * @param {WeakSet<import('postcss').Rule>} ruleCache
 * @param {WeakMap<import('postcss').Rule, RuleMeta>} ruleMeta
 * @return {boolean}
 */
function canMerge(
  ruleA,
  ruleB,
  browsers,
  compatibilityCache,
  ruleCache,
  ruleMeta
) {
  const metaA = getMeta(ruleA, ruleMeta);
  const metaB = getMeta(ruleB, ruleMeta);
  const a = metaA.selectors;
  const b = metaB.selectors;

  const selectors = a.concat(b);

  if (ruleCache.has(ruleA) && ruleCache.has(ruleB)) {
    // Both already validated
  } else if (ruleCache.has(ruleA)) {
    if (!ensureCompatibility(b, browsers, compatibilityCache)) {
      return false;
    }
  } else if (ruleCache.has(ruleB)) {
    if (!ensureCompatibility(a, browsers, compatibilityCache)) {
      return false;
    }
  } else if (!ensureCompatibility(selectors, browsers, compatibilityCache)) {
    return false;
  }

  const parent = sameParent(
    /** @type {any} */ (ruleA),
    /** @type {any} */ (ruleB)
  );
  if (
    parent &&
    ruleA.parent &&
    ruleA.parent.type === 'atrule' &&
    /** @type {import('postcss').AtRule} */ (ruleA.parent).name.includes(
      'keyframes'
    )
  ) {
    return false;
  }
  if (ruleA.some(isRuleOrAtRule) || ruleB.some(isRuleOrAtRule)) {
    return false;
  }
  return parent && (selectors.every(noVendor) || sameVendor(a, b));
}

/**
 * @param {import('postcss').ChildNode} node
 * @return {boolean}
 */
function isRuleOrAtRule(node) {
  return node.type === 'rule' || node.type === 'atrule';
}
/**
 * @param {import('postcss').ChildNode} node
 * @return {node is import('postcss').Declaration}
 */
function isDeclaration(node) {
  return node.type === 'decl';
}

/**
 * Retrieves or initializes virtual metadata for a PostCSS rule.
 *
 * This metadata caches selectors and declarations to avoid expensive AST
 * re-parsing, especially for the selectors.
 *
 * @param {import('postcss').Rule} rule The PostCSS rule to get metadata for.
 * @param {WeakMap<import('postcss').Rule, RuleMeta>} [ruleMeta] The metadata cache.
 * @return {RuleMeta} The rule's virtual metadata.
 */
function getMeta(rule, ruleMeta) {
  if (ruleMeta && rule) {
    let meta = ruleMeta.get(rule);
    if (!meta && rule.nodes) {
      meta = {
        selectors: rule.selectors,
        declarations: rule.nodes.filter(isDeclaration),
        dirty: false,
      };
      ruleMeta.set(rule, meta);
    }
    return meta ?? { selectors: [], declarations: [], dirty: false };
  }
  return {
    selectors: rule?.selectors ?? [],
    declarations: rule?.nodes?.filter(isDeclaration) ?? [],
    dirty: false,
  };
}

/**
 * Commits virtual metadata changes back to the actual PostCSS rule.
 *
 * @param {import('postcss').Rule} rule The PostCSS rule to flush.
 * @param {WeakMap<import('postcss').Rule, RuleMeta>} ruleMeta The metadata cache.
 */
function flush(rule, ruleMeta) {
  const meta = ruleMeta.get(rule);
  if (meta && meta.dirty) {
    rule.selector = meta.selectors.join(',');
    meta.dirty = false;
  }
}

/**
 * @param {import('postcss').Rule} rule
 * @return {import('postcss').Declaration[]}
 */
function getDecls(rule) {
  return rule.nodes.filter(isDeclaration);
}

/**
 * @param {...import('postcss').Rule} rules
 * @return {number}
 */
function ruleLength(...rules) {
  return rules.map((r) => (r.nodes.length ? String(r) : '')).join('').length;
}

/**
 * @param {string} prop
 * @return {{prefix: string?, base:string?, rest:string[]}}
 */
function splitProp(prop) {
  // Treat vendor prefixed properties as if they were unprefixed;
  // moving them when combined with non-prefixed properties can
  // cause issues. e.g. moving -webkit-background-clip when there
  // is a background shorthand definition.

  const parts = prop.split('-');
  if (prop[0] !== '-') {
    return {
      prefix: '',
      base: parts[0],
      rest: parts.slice(1),
    };
  }
  // Don't split css variables
  if (prop[1] === '-') {
    return {
      prefix: null,
      base: null,
      rest: [prop],
    };
  }
  // Found prefix
  return {
    prefix: parts[1],
    base: parts[2],
    rest: parts.slice(3),
  };
}

/**
 * @param {string} propA
 * @param {string} propB
 * @return {boolean}
 */
function isConflictingProp(propA, propB) {
  if (propA === propB) {
    // Same specificity
    return true;
  }
  const a = splitProp(propA);
  const b = splitProp(propB);
  // Don't resort css variables
  if (!a.base && !b.base) {
    return true;
  }

  // Different base and none is `place`;
  if (a.base !== b.base && a.base !== 'place' && b.base !== 'place') {
    return false;
  }

  // Conflict if rest-count mismatches
  if (a.rest.length !== b.rest.length) {
    return true;
  }

  /* Do not merge conflicting border properties */
  if (a.base === 'border') {
    const allRestProps = new Set([...a.rest, ...b.rest]);
    if (
      allRestProps.has('image') ||
      allRestProps.has('width') ||
      allRestProps.has('color') ||
      allRestProps.has('style')
    ) {
      return true;
    }
  }
  // Conflict if rest parameters are equal (same but unprefixed)
  return a.rest.every((s, index) => b.rest[index] === s);
}

/**
 * @param {import('postcss').Rule} first
 * @param {import('postcss').Rule} second
 * @return {boolean} merged
 */
function mergeParents(first, second) {
  // Null check for detached rules
  if (!first.parent || !second.parent) {
    return false;
  }

  // Check if parents share node
  if (first.parent === second.parent) {
    return false;
  }

  // sameParent() already called by canMerge()

  second.remove();
  first.parent.append(second);
  return true;
}

/**
 * @param {import('postcss').Rule} first
 * @param {import('postcss').Rule} second
 * @param {string[]} browsers
 * @param {Map<string, boolean>} compatibilityCache
 * @param {WeakSet<import('postcss').Rule>} ruleCache
 * @param {WeakMap<import('postcss').Rule, RuleMeta>} ruleMeta
 * @return {import('postcss').Rule} mergedRule
 */
function partialMerge(
  first,
  second,
  browsers,
  compatibilityCache,
  ruleCache,
  ruleMeta
) {
  if (ruleMeta) {
    flush(first, ruleMeta);
  }
  const metaFirst = getMeta(first, ruleMeta);
  const metaSecond = getMeta(second, ruleMeta);
  let intersection = intersect(metaFirst.declarations, metaSecond.declarations);
  if (intersection.length === 0) {
    return second;
  }
  let nextRule = second.next();
  if (!nextRule) {
    // Grab next cousin
    /** @type {any} */
    const parentSibling =
      /** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
        second.parent
      ).next();
    nextRule = parentSibling && parentSibling.nodes && parentSibling.nodes[0];
  }
  if (
    nextRule?.type === 'rule' &&
    canMerge(
      second,
      nextRule,
      browsers,
      compatibilityCache,
      ruleCache,
      ruleMeta
    )
  ) {
    const metaNext = getMeta(nextRule, ruleMeta);
    let nextIntersection = intersect(
      metaSecond.declarations,
      metaNext.declarations
    );
    if (nextIntersection.length > intersection.length) {
      mergeParents(second, nextRule);
      first = second;
      second = nextRule;
      intersection = nextIntersection;
    }
  }

  const metaFirstActual = getMeta(first, ruleMeta);
  const metaSecondActual = getMeta(second, ruleMeta);
  let firstDecls = [...metaFirstActual.declarations];
  let secondDecls = [...metaSecondActual.declarations];

  // Filter out intersections with later conflicts in First
  intersection = intersection.filter((decl, intersectIndex) => {
    const indexOfDecl = indexOfDeclaration(firstDecls, decl);
    const nextConflictInFirst = firstDecls
      .slice(indexOfDecl + 1)
      .filter((d) => isConflictingProp(d.prop, decl.prop));
    if (nextConflictInFirst.length === 0) {
      return true;
    }
    const nextConflictInIntersection = intersection
      .slice(intersectIndex + 1)
      .filter((d) => isConflictingProp(d.prop, decl.prop));
    if (nextConflictInFirst.length !== nextConflictInIntersection.length) {
      return false;
    }
    return nextConflictInFirst.every((d, index) =>
      declarationIsEqual(d, nextConflictInIntersection[index])
    );
  });

  // Filter out intersections with previous conflicts in Second
  intersection = intersection.filter((decl) => {
    const nextConflictIndex = secondDecls.findIndex((d) =>
      isConflictingProp(d.prop, decl.prop)
    );
    if (nextConflictIndex === -1) {
      return false;
    }
    if (!declarationIsEqual(secondDecls[nextConflictIndex], decl)) {
      return false;
    }
    if (
      decl.prop.toLowerCase() !== 'direction' &&
      decl.prop.toLowerCase() !== 'unicode-bidi' &&
      secondDecls.some(
        (declaration) => declaration.prop.toLowerCase() === 'all'
      )
    ) {
      return false;
    }
    secondDecls.splice(nextConflictIndex, 1);
    return true;
  });

  if (intersection.length === 0) {
    // Nothing to merge
    return second;
  }

  const receivingBlock = second.clone();
  const firstSelectors = metaFirstActual.selectors;
  const secondSelectors = metaSecondActual.selectors;

  receivingBlock.selector = [...firstSelectors, ...secondSelectors].join();
  receivingBlock.nodes = [];

  /** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
    second.parent
  ).insertBefore(second, receivingBlock);

  const firstClone = first.clone({ selectors: firstSelectors });
  const secondClone = second.clone({ selectors: secondSelectors });

  /**
   * @param {function(import('postcss').Declaration):void} callback
   * @this void
   * @return {function(import('postcss').Declaration)}
   */
  function moveDecl(callback) {
    return (decl) => {
      if (indexOfDeclaration(intersection, decl) !== -1) {
        callback.call(this, decl);
      }
    };
  }
  firstClone.walkDecls(
    moveDecl((decl) => {
      decl.remove();
      receivingBlock.append(decl);
    })
  );
  secondClone.walkDecls(moveDecl((decl) => decl.remove()));

  // Ensure original rules are flushed for accurate length comparison
  if (ruleMeta) {
    flush(first, ruleMeta);
    flush(second, ruleMeta);
  }

  const merged = ruleLength(firstClone, receivingBlock, secondClone);
  const original = ruleLength(first, second);
  if (merged < original) {
    first.replaceWith(firstClone);
    second.replaceWith(secondClone);
    [firstClone, receivingBlock, secondClone].forEach((r) => {
      if (r.nodes.length === 0) {
        r.remove();
      }
    });
    if (!secondClone.parent) {
      ruleCache?.add(receivingBlock);
      return receivingBlock;
    }
    ruleCache?.add(receivingBlock);
    ruleCache?.add(secondClone);
    ruleMeta?.delete(first);
    ruleMeta?.delete(second);
    return secondClone;
  } else {
    receivingBlock.remove();
    return second;
  }
}

/**
 * @param {string[]} browsers
 * @param {Map<string, boolean>} compatibilityCache
 * @param {WeakSet<import('postcss').Rule>} ruleCache
 * @param {WeakMap<import('postcss').Rule, RuleMeta>} ruleMeta
 * @return {{ merger: function(import('postcss').Rule): void, clean: function(): void }}
 */
function selectorMerger(browsers, compatibilityCache, ruleCache, ruleMeta) {
  /** @type {import('postcss').Rule | null} */
  let cache = null;
  return {
    merger(rule) {
      // Prime the cache with the first rule, or alternately ensure that it is
      // safe to merge both declarations before continuing
      if (
        !cache ||
        !canMerge(
          rule,
          cache,
          browsers,
          compatibilityCache,
          ruleCache,
          ruleMeta
        )
      ) {
        if (cache) {
          flush(cache, ruleMeta);
        }
        cache = rule;
        return;
      }
      // Ensure that we don't deduplicate the same rule; this is sometimes
      // caused by a partial merge
      if (cache === rule) {
        cache = rule;
        return;
      }

      // Parents merge: check if the rules have same parents, but not same parent nodes
      mergeParents(cache, rule);

      // Merge when declarations are exactly equal
      // e.g. h1 { color: red } h2 { color: red }
      if (
        sameDeclarationsAndOrder(
          getMeta(rule, ruleMeta).declarations,
          getMeta(cache, ruleMeta).declarations
        )
      ) {
        const metaRule = getMeta(rule, ruleMeta);
        const metaCache = getMeta(cache, ruleMeta);
        metaRule.selectors = [...metaCache.selectors, ...metaRule.selectors];
        metaRule.dirty = true;
        cache.remove();
        ruleMeta?.delete(cache);
        cache = rule;
        ruleCache?.add(rule);
        return;
      }
      // Merge when both selectors are exactly equal
      // e.g. a { color: blue } a { font-weight: bold }
      if (
        getMeta(cache, ruleMeta).selectors.join(',') ===
        getMeta(rule, ruleMeta).selectors.join(',')
      ) {
        const cachedDecls = getMeta(cache, ruleMeta).declarations;
        rule.walk((node) => {
          if (
            node.type === 'decl' &&
            indexOfDeclaration(cachedDecls, node) !== -1
          ) {
            node.remove();
            return;
          }
          /** @type {import('postcss').Rule} */ (cache).append(node);
        });
        getMeta(cache, ruleMeta).declarations = getDecls(cache);
        rule.remove();
        ruleMeta?.delete(rule);
        return;
      }
      // Partial merge: check if the rule contains a subset of the last; if
      // so create a joined selector with the subset, if smaller.
      cache = partialMerge(
        cache,
        rule,
        browsers,
        compatibilityCache,
        ruleCache,
        ruleMeta
      );
    },
    // Flushes any remaining rule in the cache to avoid memory leaks.
    clean() {
      if (cache) {
        flush(cache, ruleMeta);
      }
    },
  };
}

/**
 * @typedef {{ overrideBrowserslist?: string | string[] }} AutoprefixerOptions
 * @typedef {Pick<browserslist.Options, 'stats' | 'path' | 'env'>} BrowserslistOptions
 * @typedef {AutoprefixerOptions & BrowserslistOptions} Options
 */

/**
 * @type {import('postcss').PluginCreator<Options>}
 * @param {Options} opts
 * @return {import('postcss').Plugin}
 */
function pluginCreator(opts = {}) {
  return {
    postcssPlugin: 'postcss-merge-rules',

    /**
     * @param {import('postcss').Result & {opts: BrowserslistOptions & {file?: string}}} result
     */
    prepare(result) {
      const { stats, env, from, file } = result.opts || {};
      const browsers = browserslist(opts.overrideBrowserslist, {
        stats: opts.stats || stats,
        path: opts.path || dirname(from || file || __filename),
        env: opts.env || env,
      });

      const compatibilityCache = new Map();

      // Use WeakSet and WeakMap to avoid memory leaks because the keys are objects.
      const ruleCache = new WeakSet();
      const ruleMeta = new WeakMap();

      return {
        OnceExit(css) {
          const { merger, clean } = selectorMerger(
            browsers,
            compatibilityCache,
            ruleCache,
            ruleMeta
          );
          css.walkRules(merger);
          clean();
        },
      };
    },
  };
}

pluginCreator.postcss = true;
module.exports = pluginCreator;
