/**
@license
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
*/

'use strict';

import {parse, StyleNode} from './css-parse.js';
// prettier-ignore
import {nativeShadow, nativeCssVariables, disableRuntime} from './style-settings.js';
import StyleTransformer from './style-transformer.js';
import * as StyleUtil from './style-util.js';
import StyleProperties from './style-properties.js';
// prettier-ignore
import {ensureStylePlaceholder, getStylePlaceholder} from './style-placeholder.js';
import StyleInfo from './style-info.js';
import StyleCache from './style-cache.js';
// prettier-ignore
import {flush as watcherFlush, getOwnerScope, getCurrentScope} from './document-watcher.js';
import templateMap from './template-map.js';
import * as ApplyShimUtils from './apply-shim-utils.js';
import {updateNativeProperties, detectMixin} from './common-utils.js';
// prettier-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import {CustomStyleInterfaceInterface, CustomStyleProvider} from './custom-style-interface.js';

/** @type {!Object<string, string>} */
const adoptedCssTextMap = {};

/**
 * @const {StyleCache}
 */
const styleCache = new StyleCache();

export default class ScopingShim {
  constructor() {
    this._scopeCounter = {};
    this._documentOwner = /** @type {!HTMLElement} */ (document.documentElement);
    let ast = new StyleNode();
    ast['rules'] = [];
    this._documentOwnerStyleInfo = StyleInfo.set(
      this._documentOwner,
      new StyleInfo(ast)
    );
    this._elementsHaveApplied = false;
    /** @type {?Object} */
    this._applyShim = null;
    /** @type {?CustomStyleInterfaceInterface} */
    this._customStyleInterface = null;
  }
  flush() {
    watcherFlush();
  }
  _generateScopeSelector(name) {
    let id = (this._scopeCounter[name] = (this._scopeCounter[name] || 0) + 1);
    return `${name}-${id}`;
  }
  getStyleAst(style) {
    return StyleUtil.rulesForStyle(style);
  }
  styleAstToString(ast) {
    return StyleUtil.toCssText(ast);
  }
  _gatherStyles(template) {
    return StyleUtil.gatherStyleText(template.content);
  }
  /**
   * Prepare the styling and template for the given element type
   *
   * @param {!HTMLTemplateElement} template
   * @param {string} elementName
   * @param {string=} typeExtension
   */
  prepareTemplate(template, elementName, typeExtension) {
    this.prepareTemplateDom(template, elementName);
    this.prepareTemplateStyles(template, elementName, typeExtension);
  }
  /**
   * Prepare styling for the given element type
   * @param {!HTMLTemplateElement} template
   * @param {string} elementName
   * @param {string=} typeExtension
   */
  prepareTemplateStyles(template, elementName, typeExtension) {
    if (template._prepared || disableRuntime) {
      return;
    }
    // style placeholders are only used when ShadyDOM is active
    if (!nativeShadow) {
      ensureStylePlaceholder(elementName);
    }
    template._prepared = true;
    template.name = elementName;
    template.extends = typeExtension;
    templateMap[elementName] = template;
    let cssBuild = StyleUtil.getCssBuild(template);
    const optimalBuild = StyleUtil.isOptimalCssBuild(cssBuild);
    let info = {
      is: elementName,
      extends: typeExtension,
    };
    let cssText =
      this._gatherStyles(template) + (adoptedCssTextMap[elementName] || '');
    // check if the styling has mixin definitions or uses
    this._ensure();
    if (!optimalBuild) {
      let hasMixins = !cssBuild && detectMixin(cssText);
      let ast = parse(cssText);
      // only run the applyshim transforms if there is a mixin involved
      if (hasMixins && nativeCssVariables && this._applyShim) {
        this._applyShim['transformRules'](ast, elementName);
      }
      template['_styleAst'] = ast;
    }
    let ownPropertyNames = [];
    if (!nativeCssVariables) {
      ownPropertyNames = StyleProperties.decorateStyles(template['_styleAst']);
    }
    if (!ownPropertyNames.length || nativeCssVariables) {
      let root = nativeShadow ? template.content : null;
      let placeholder = getStylePlaceholder(elementName);
      let style = this._generateStaticStyle(
        info,
        template['_styleAst'],
        root,
        placeholder,
        cssBuild,
        optimalBuild ? cssText : ''
      );
      template._style = style;
    }
    template._ownPropertyNames = ownPropertyNames;
  }

  /**
   * @param {!Array<string>} cssTextArray
   * @param {string} elementName
   */
  prepareAdoptedCssText(cssTextArray, elementName) {
    adoptedCssTextMap[elementName] = cssTextArray.join(' ');
  }
  /**
   * Prepare template for the given element type
   * @param {!HTMLTemplateElement} template
   * @param {string} elementName
   */
  prepareTemplateDom(template, elementName) {
    if (disableRuntime) {
      return;
    }
    const cssBuild = StyleUtil.getCssBuild(template);
    if (!nativeShadow && cssBuild !== 'shady' && !template._domPrepared) {
      template._domPrepared = true;
      StyleTransformer.domAddScope(template.content, elementName);
    }
  }
  /**
   * @param {!{is: string, extends: (string|undefined)}} info
   * @param {!StyleNode} rules
   * @param {DocumentFragment} shadowroot
   * @param {Node} placeholder
   * @param {string} cssBuild
   * @param {string=} cssText
   * @return {?HTMLStyleElement}
   */
  _generateStaticStyle(
    info,
    rules,
    shadowroot,
    placeholder,
    cssBuild,
    cssText
  ) {
    cssText = StyleTransformer.elementStyles(
      info,
      rules,
      null,
      cssBuild,
      cssText
    );
    if (cssText.length) {
      return StyleUtil.applyCss(cssText, info.is, shadowroot, placeholder);
    }
    return null;
  }
  _prepareHost(host) {
    const {is, typeExtension} = StyleUtil.getIsExtends(host);
    const placeholder = getStylePlaceholder(is);
    const template = templateMap[is];
    if (!template) {
      return;
    }
    const ast = template['_styleAst'];
    const ownStylePropertyNames = template._ownPropertyNames;
    const cssBuild = StyleUtil.getCssBuild(template);
    const styleInfo = new StyleInfo(
      ast,
      placeholder,
      ownStylePropertyNames,
      is,
      typeExtension,
      cssBuild
    );
    StyleInfo.set(host, styleInfo);
    return styleInfo;
  }
  /**
   * Returns a boolean that indicates if styles need to be reprocessed because
   * the apply shim is now available.
   * @return {boolean}
   */
  _ensureApplyShim() {
    if (!this._applyShim && window.ShadyCSS && window.ShadyCSS.ApplyShim) {
      this._applyShim = /** @type {!Object} */ (window.ShadyCSS.ApplyShim);
      this._applyShim['invalidCallback'] = ApplyShimUtils.invalidate;
      return true;
    }
    return false;
  }
  _ensureCustomStyleInterface() {
    if (this._customStyleInterface) {
      return;
    } else if (window.ShadyCSS && window.ShadyCSS.CustomStyleInterface) {
      this._customStyleInterface = /** @type {!CustomStyleInterfaceInterface} */ (window
        .ShadyCSS.CustomStyleInterface);
      /** @type {function(!HTMLStyleElement)} */
      this._customStyleInterface['transformCallback'] = (style) => {
        this.transformCustomStyleForDocument(style);
      };
      this._customStyleInterface['validateCallback'] = () => {
        requestAnimationFrame(() => {
          if (
            this._customStyleInterface['enqueued'] ||
            this._elementsHaveApplied
          ) {
            this.flushCustomStyles();
          }
        });
      };
    }
  }
  /**
   * Returns a boolean that indicates if styles need to be reprocessed because
   * the apply shim is now available.
   * @return {boolean}
   */
  _ensure() {
    const needsApplyShimUpdate = this._ensureApplyShim();
    this._ensureCustomStyleInterface();
    return needsApplyShimUpdate;
  }
  /**
   * Flush and apply custom styles to document
   */
  flushCustomStyles() {
    if (disableRuntime) {
      return;
    }
    const needsApplyShimUpdate = this._ensure();
    if (!this._customStyleInterface) {
      return;
    }
    let customStyles = this._customStyleInterface['processStyles']();
    // early return if custom-styles don't need validation
    if (!needsApplyShimUpdate && !this._customStyleInterface['enqueued']) {
      return;
    }
    // bail if custom styles are built optimally
    if (StyleUtil.isOptimalCssBuild(this._documentOwnerStyleInfo.cssBuild)) {
      return;
    }
    if (!nativeCssVariables) {
      this._reorderCustomStylesRules(customStyles);
      this._updateProperties(this._documentOwner, this._documentOwnerStyleInfo);
      this._applyCustomStyles(customStyles);
      if (this._elementsHaveApplied) {
        // if custom elements have upgraded and there are no native css variables, we must recalculate the whole tree
        this.styleDocument();
      }
    } else if (!this._documentOwnerStyleInfo.cssBuild) {
      this._revalidateCustomStyleApplyShim(customStyles);
    }
    this._customStyleInterface['enqueued'] = false;
  }
  /**
   * Reorder of custom styles for Custom Property shim
   * @param {!Array<!CustomStyleProvider>} customStyles
   */
  _reorderCustomStylesRules(customStyles) {
    const styles = customStyles
      .map((c) => this._customStyleInterface['getStyleForCustomStyle'](c))
      .filter((s) => !!s);
    // sort styles in document order
    styles.sort((a, b) => {
      // use `b.compare(a)` to be more straightforward
      const position = b.compareDocumentPosition(a);
      if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
        // A is after B, A should be higher sorted
        return 1;
      } else if (position & Node.DOCUMENT_POSITION_PRECEDING) {
        // A is before B, A should be lower sorted
        return -1;
      } else {
        return 0;
      }
    });
    // sort ast ordering for document
    this._documentOwnerStyleInfo.styleRules['rules'] = styles.map((s) =>
      StyleUtil.rulesForStyle(s)
    );
  }
  /**
   * Apply styles for the given element
   *
   * @param {!HTMLElement} host
   * @param {Object=} overrideProps
   */
  styleElement(host, overrideProps) {
    if (disableRuntime) {
      if (overrideProps) {
        if (!StyleInfo.get(host)) {
          StyleInfo.set(host, new StyleInfo(null));
        }
        const styleInfo = /** @type {!StyleInfo} */ (StyleInfo.get(host));
        this._mixOverrideStyleProps(styleInfo, overrideProps);
        this.styleElementNativeVariables(host, styleInfo);
      }
      return;
    }
    const styleInfo = StyleInfo.get(host) || this._prepareHost(host);
    // if there is no style info at this point, bail
    if (!styleInfo) {
      return;
    }
    // Only trip the `elementsHaveApplied` flag if a node other that the root document has `applyStyle` called
    if (!this._isRootOwner(host)) {
      this._elementsHaveApplied = true;
    }
    if (overrideProps) {
      this._mixOverrideStyleProps(styleInfo, overrideProps);
    }
    if (!nativeCssVariables) {
      this.styleElementShimVariables(host, styleInfo);
    } else {
      this.styleElementNativeVariables(host, styleInfo);
    }
  }
  /**
   * @param {!StyleInfo} styleInfo
   * @param {Object} overrideProps
   */
  _mixOverrideStyleProps(styleInfo, overrideProps) {
    styleInfo.overrideStyleProperties = styleInfo.overrideStyleProperties || {};
    Object.assign(styleInfo.overrideStyleProperties, overrideProps);
  }
  /**
   * @param {!HTMLElement} host
   * @param {!StyleInfo} styleInfo
   */
  styleElementShimVariables(host, styleInfo) {
    this.flush();
    this._updateProperties(host, styleInfo);
    if (
      styleInfo.ownStylePropertyNames &&
      styleInfo.ownStylePropertyNames.length
    ) {
      this._applyStyleProperties(host, styleInfo);
    }
  }
  /**
   * @param {!HTMLElement} host
   * @param {!StyleInfo} styleInfo
   */
  styleElementNativeVariables(host, styleInfo) {
    const {is} = StyleUtil.getIsExtends(host);
    if (styleInfo.overrideStyleProperties) {
      updateNativeProperties(host, styleInfo.overrideStyleProperties);
    }
    const template = templateMap[is];
    // bail early if there is no shadowroot for this element
    if (!template && !this._isRootOwner(host)) {
      return;
    }
    // bail early if the template was built with polymer-css-build
    if (template && StyleUtil.elementHasBuiltCss(template)) {
      return;
    }
    if (
      template &&
      template._style &&
      !ApplyShimUtils.templateIsValid(template)
    ) {
      // update template
      if (!ApplyShimUtils.templateIsValidating(template)) {
        this._ensure();
        this._applyShim &&
          this._applyShim['transformRules'](template['_styleAst'], is);
        template._style.textContent = StyleTransformer.elementStyles(
          host,
          styleInfo.styleRules
        );
        ApplyShimUtils.startValidatingTemplate(template);
      }
      // update instance if native shadowdom
      if (nativeShadow) {
        let root = host.shadowRoot;
        if (root) {
          let style = root.querySelector('style');
          if (style) {
            style.textContent = StyleTransformer.elementStyles(
              host,
              styleInfo.styleRules
            );
          }
        }
      }
      styleInfo.styleRules = template['_styleAst'];
    }
  }
  _styleOwnerForNode(node) {
    let root = StyleUtil.wrap(node).getRootNode();
    let host = root.host;
    if (host) {
      if (StyleInfo.get(host) || this._prepareHost(host)) {
        return host;
      } else {
        return this._styleOwnerForNode(host);
      }
    }
    return this._documentOwner;
  }
  _isRootOwner(node) {
    return node === this._documentOwner;
  }
  _applyStyleProperties(host, styleInfo) {
    let is = StyleUtil.getIsExtends(host).is;
    let cacheEntry = styleCache.fetch(
      is,
      styleInfo.styleProperties,
      styleInfo.ownStylePropertyNames
    );
    let cachedScopeSelector = cacheEntry && cacheEntry.scopeSelector;
    let cachedStyle = cacheEntry ? cacheEntry.styleElement : null;
    let oldScopeSelector = styleInfo.scopeSelector;
    // only generate new scope if cached style is not found
    styleInfo.scopeSelector =
      cachedScopeSelector || this._generateScopeSelector(is);
    let style = StyleProperties.applyElementStyle(
      host,
      styleInfo.styleProperties,
      styleInfo.scopeSelector,
      cachedStyle
    );
    if (!nativeShadow) {
      StyleProperties.applyElementScopeSelector(
        host,
        styleInfo.scopeSelector,
        oldScopeSelector
      );
    }
    if (!cacheEntry) {
      styleCache.store(
        is,
        styleInfo.styleProperties,
        style,
        styleInfo.scopeSelector
      );
    }
    return style;
  }
  _updateProperties(host, styleInfo) {
    let owner = this._styleOwnerForNode(host);
    let ownerStyleInfo = StyleInfo.get(owner);
    let ownerProperties = ownerStyleInfo.styleProperties;
    // style owner has not updated properties yet
    // go up the chain and force property update,
    // except if the owner is the document
    if (owner !== this._documentOwner && !ownerProperties) {
      this._updateProperties(owner, ownerStyleInfo);
      ownerProperties = ownerStyleInfo.styleProperties;
    }
    let props = Object.create(ownerProperties || null);
    let hostAndRootProps = StyleProperties.hostAndRootPropertiesForScope(
      host,
      styleInfo.styleRules,
      styleInfo.cssBuild
    );
    let propertyData = StyleProperties.propertyDataFromStyles(
      ownerStyleInfo.styleRules,
      host
    );
    let propertiesMatchingHost = propertyData.properties;
    Object.assign(
      props,
      hostAndRootProps.hostProps,
      propertiesMatchingHost,
      hostAndRootProps.rootProps
    );
    this._mixinOverrideStyles(props, styleInfo.overrideStyleProperties);
    StyleProperties.reify(props);
    styleInfo.styleProperties = props;
  }
  _mixinOverrideStyles(props, overrides) {
    for (let p in overrides) {
      let v = overrides[p];
      // skip override props if they are not truthy or 0
      // in order to fall back to inherited values
      if (v || v === 0) {
        props[p] = v;
      }
    }
  }
  /**
   * Update styles of the whole document
   *
   * @param {Object=} properties
   */
  styleDocument(properties) {
    this.styleSubtree(this._documentOwner, properties);
  }
  /**
   * Update styles of a subtree
   *
   * @param {!HTMLElement} host
   * @param {Object=} properties
   */
  styleSubtree(host, properties) {
    const wrappedHost = StyleUtil.wrap(host);
    const root = wrappedHost.shadowRoot;
    const isRootOwner = this._isRootOwner(host);
    if (root || isRootOwner) {
      this.styleElement(host, properties);
    }
    const descendantRoot = isRootOwner ? wrappedHost : root;
    if (descendantRoot) {
      const descendantHosts = Array.from(
        descendantRoot.querySelectorAll('*')
      ).filter((x) => StyleUtil.wrap(x).shadowRoot);
      for (let i = 0; i < descendantHosts.length; i++) {
        this.styleSubtree(descendantHosts[i]);
      }
    }
  }
  /* Custom Style operations */
  _revalidateCustomStyleApplyShim(customStyles) {
    for (let i = 0; i < customStyles.length; i++) {
      let c = customStyles[i];
      let s = this._customStyleInterface['getStyleForCustomStyle'](c);
      if (s) {
        this._revalidateApplyShim(s);
      }
    }
  }
  _applyCustomStyles(customStyles) {
    for (let i = 0; i < customStyles.length; i++) {
      let c = customStyles[i];
      let s = this._customStyleInterface['getStyleForCustomStyle'](c);
      if (s) {
        StyleProperties.applyCustomStyle(
          s,
          this._documentOwnerStyleInfo.styleProperties
        );
      }
    }
  }
  transformCustomStyleForDocument(style) {
    const cssBuild = StyleUtil.getCssBuild(style);
    if (cssBuild !== this._documentOwnerStyleInfo.cssBuild) {
      this._documentOwnerStyleInfo.cssBuild = cssBuild;
    }
    if (StyleUtil.isOptimalCssBuild(cssBuild)) {
      return;
    }
    let ast = StyleUtil.rulesForStyle(style);
    StyleUtil.forEachRule(ast, (rule) => {
      if (nativeShadow) {
        StyleTransformer.normalizeRootSelector(rule);
      } else {
        StyleTransformer.documentRule(rule);
      }
      if (nativeCssVariables && cssBuild === '') {
        this._ensure();
        this._applyShim && this._applyShim['transformRule'](rule);
      }
    });
    if (nativeCssVariables) {
      style.textContent = StyleUtil.toCssText(ast);
    } else {
      this._documentOwnerStyleInfo.styleRules['rules'].push(ast);
    }
  }
  _revalidateApplyShim(style) {
    if (nativeCssVariables && this._applyShim) {
      let ast = StyleUtil.rulesForStyle(style);
      this._ensure();
      this._applyShim['transformRules'](ast);
      style.textContent = StyleUtil.toCssText(ast);
    }
  }
  getComputedStyleValue(element, property) {
    let value;
    if (!nativeCssVariables) {
      // element is either a style host, or an ancestor of a style host
      let styleInfo =
        StyleInfo.get(element) ||
        StyleInfo.get(this._styleOwnerForNode(element));
      value = styleInfo.styleProperties[property];
    }
    // fall back to the property value from the computed styling
    value =
      value || window.getComputedStyle(element).getPropertyValue(property);
    // trim whitespace that can come after the `:` in css
    // example: padding: 2px -> " 2px"
    return value ? value.trim() : '';
  }
  // given an element and a classString, replaces
  // the element's class with the provided classString and adds
  // any necessary ShadyCSS static and property based scoping selectors
  setElementClass(element, classString) {
    let root = StyleUtil.wrap(element).getRootNode();
    let classes;
    if (classString) {
      let definitelyString =
        typeof classString === 'string' ? classString : String(classString);
      classes = definitelyString.split(/\s/);
    } else {
      classes = [];
    }
    let scopeName = root.host && root.host.localName;
    // If no scope, try to discover scope name from existing class.
    // This can occur if, for example, a template stamped element that
    // has been scoped is manipulated when not in a root.
    if (!scopeName) {
      var classAttr = element.getAttribute('class');
      if (classAttr) {
        let k$ = classAttr.split(/\s/);
        for (let i = 0; i < k$.length; i++) {
          if (k$[i] === StyleTransformer.SCOPE_NAME) {
            scopeName = k$[i + 1];
            break;
          }
        }
      }
    }
    if (scopeName) {
      classes.push(StyleTransformer.SCOPE_NAME, scopeName);
    }
    if (!nativeCssVariables) {
      let styleInfo = StyleInfo.get(element);
      if (styleInfo && styleInfo.scopeSelector) {
        classes.push(StyleProperties.XSCOPE_NAME, styleInfo.scopeSelector);
      }
    }
    StyleUtil.setElementClassRaw(element, classes.join(' '));
  }
  _styleInfoForNode(node) {
    return StyleInfo.get(node);
  }
  /**
   * @param {!Element} node
   * @param {string} scope
   */
  scopeNode(node, scope) {
    StyleTransformer.element(node, scope);
  }
  /**
   * @param {!Element} node
   * @param {string} scope
   */
  unscopeNode(node, scope) {
    StyleTransformer.element(node, scope, true);
  }
  /**
   * @param {!Node} node
   * @return {string}
   */
  scopeForNode(node) {
    return getOwnerScope(node);
  }
  /**
   * @param {!Element} node
   * @return {string}
   */
  currentScopeForNode(node) {
    return getCurrentScope(node);
  }
}

/* exports */
/* eslint-disable no-self-assign */
ScopingShim.prototype['flush'] = ScopingShim.prototype.flush;
ScopingShim.prototype['prepareTemplate'] =
  ScopingShim.prototype.prepareTemplate;
ScopingShim.prototype['styleElement'] = ScopingShim.prototype.styleElement;
ScopingShim.prototype['styleDocument'] = ScopingShim.prototype.styleDocument;
ScopingShim.prototype['styleSubtree'] = ScopingShim.prototype.styleSubtree;
ScopingShim.prototype['getComputedStyleValue'] =
  ScopingShim.prototype.getComputedStyleValue;
ScopingShim.prototype['setElementClass'] =
  ScopingShim.prototype.setElementClass;
ScopingShim.prototype['_styleInfoForNode'] =
  ScopingShim.prototype._styleInfoForNode;
ScopingShim.prototype['transformCustomStyleForDocument'] =
  ScopingShim.prototype.transformCustomStyleForDocument;
ScopingShim.prototype['getStyleAst'] = ScopingShim.prototype.getStyleAst;
ScopingShim.prototype['styleAstToString'] =
  ScopingShim.prototype.styleAstToString;
ScopingShim.prototype['flushCustomStyles'] =
  ScopingShim.prototype.flushCustomStyles;
ScopingShim.prototype['scopeNode'] = ScopingShim.prototype.scopeNode;
ScopingShim.prototype['unscopeNode'] = ScopingShim.prototype.unscopeNode;
ScopingShim.prototype['scopeForNode'] = ScopingShim.prototype.scopeForNode;
ScopingShim.prototype['currentScopeForNode'] =
  ScopingShim.prototype.currentScopeForNode;
ScopingShim.prototype['prepareAdoptedCssText'] =
  ScopingShim.prototype.prepareAdoptedCssText;
/* eslint-enable no-self-assign */
Object.defineProperties(ScopingShim.prototype, {
  'nativeShadow': {
    get() {
      return nativeShadow;
    },
  },
  'nativeCss': {
    get() {
      return nativeCssVariables;
    },
  },
});
