/**
@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
*/
import { PolymerElement } from '../../polymer-element.js';

import { Debouncer } from '../utils/debounce.js';
import { enqueueDebouncer, flush } from '../utils/flush.js';
import { microTask } from '../utils/async.js';
import { root } from '../utils/path.js';
import { wrap } from '../utils/wrap.js';
import { hideElementsGlobally } from '../utils/hide-template-controls.js';
import { fastDomIf, strictTemplatePolicy, suppressTemplateNotifications } from '../utils/settings.js';
import { showHideChildren, templatize } from '../utils/templatize.js';

/**
 * @customElement
 * @polymer
 * @extends PolymerElement
 * @summary Base class for dom-if element; subclassed into concrete
 *   implementation.
 */
class DomIfBase extends PolymerElement {

  // Not needed to find template; can be removed once the analyzer
  // can find the tag name from customElements.define call
  static get is() { return 'dom-if'; }

  static get template() { return null; }

  static get properties() {

    return {

      /**
       * Fired whenever DOM is added or removed/hidden by this template (by
       * default, rendering occurs lazily).  To force immediate rendering, call
       * `render`.
       *
       * @event dom-change
       */

      /**
       * A boolean indicating whether this template should stamp.
       */
      if: {
        type: Boolean,
        observer: '__debounceRender'
      },

      /**
       * When true, elements will be removed from DOM and discarded when `if`
       * becomes false and re-created and added back to the DOM when `if`
       * becomes true.  By default, stamped elements will be hidden but left
       * in the DOM when `if` becomes false, which is generally results
       * in better performance.
       */
      restamp: {
        type: Boolean,
        observer: '__debounceRender'
      },

      /**
       * When the global `suppressTemplateNotifications` setting is used, setting
       * `notifyDomChange: true` will enable firing `dom-change` events on this
       * element.
       */
      notifyDomChange: {
        type: Boolean
      }
    };

  }

  constructor() {
    super();
    this.__renderDebouncer = null;
    this._lastIf = false;
    this.__hideTemplateChildren__ = false;
    /** @type {!HTMLTemplateElement|undefined} */
    this.__template;
    /** @type {!TemplateInfo|undefined} */
    this._templateInfo;
  }

  __debounceRender() {
    // Render is async for 2 reasons:
    // 1. To eliminate dom creation trashing if user code thrashes `if` in the
    //    same turn. This was more common in 1.x where a compound computed
    //    property could result in the result changing multiple times, but is
    //    mitigated to a large extent by batched property processing in 2.x.
    // 2. To avoid double object propagation when a bag including values bound
    //    to the `if` property as well as one or more hostProps could enqueue
    //    the <dom-if> to flush before the <template>'s host property
    //    forwarding. In that scenario creating an instance would result in
    //    the host props being set once, and then the enqueued changes on the
    //    template would set properties a second time, potentially causing an
    //    object to be set to an instance more than once.  Creating the
    //    instance async from flushing data ensures this doesn't happen. If
    //    we wanted a sync option in the future, simply having <dom-if> flush
    //    (or clear) its template's pending host properties before creating
    //    the instance would also avoid the problem.
    this.__renderDebouncer = Debouncer.debounce(
          this.__renderDebouncer
        , microTask
        , () => this.__render());
    enqueueDebouncer(this.__renderDebouncer);
  }

  /**
   * @override
   * @return {void}
   */
  disconnectedCallback() {
    super.disconnectedCallback();
    const parent = wrap(this).parentNode;
    if (!parent || (parent.nodeType == Node.DOCUMENT_FRAGMENT_NODE &&
        !wrap(parent).host)) {
      this.__teardownInstance();
    }
  }

  /**
   * @override
   * @return {void}
   */
  connectedCallback() {
    super.connectedCallback();
    if (!hideElementsGlobally()) {
      this.style.display = 'none';
    }
    if (this.if) {
      this.__debounceRender();
    }
  }

  /**
   * Ensures a template has been assigned to `this.__template`.  If it has not
   * yet been, it querySelectors for it in its children and if it does not yet
   * exist (e.g. in parser-generated case), opens a mutation observer and
   * waits for it to appear (returns false if it has not yet been found,
   * otherwise true).  In the `removeNestedTemplates` case, the "template" will
   * be the `dom-if` element itself.
   *
   * @return {boolean} True when a template has been found, false otherwise
   */
  __ensureTemplate() {
    if (!this.__template) {
      // When `removeNestedTemplates` is true, the "template" is the element
      // itself, which has been given a `_templateInfo` property
      const thisAsTemplate = /** @type {!HTMLTemplateElement} */ (
          /** @type {!HTMLElement} */ (this));
      let template = thisAsTemplate._templateInfo ?
          thisAsTemplate :
          /** @type {!HTMLTemplateElement} */
          (wrap(thisAsTemplate).querySelector('template'));
      if (!template) {
        // Wait until childList changes and template should be there by then
        let observer = new MutationObserver(() => {
          if (wrap(this).querySelector('template')) {
            observer.disconnect();
            this.__render();
          } else {
            throw new Error('dom-if requires a <template> child');
          }
        });
        observer.observe(this, {childList: true});
        return false;
      }
      this.__template = template;
    }
    return true;
  }

  /**
   * Ensures a an instance of the template has been created and inserted. This
   * method may return false if the template has not yet been found or if
   * there is no `parentNode` to insert the template into (in either case,
   * connection or the template-finding mutation observer firing will queue
   * another render, causing this method to be called again at a more
   * appropriate time).
   *
   * Subclasses should implement the following methods called here:
   * - `__hasInstance`
   * - `__createAndInsertInstance`
   * - `__getInstanceNodes`
   *
   * @return {boolean} True if the instance was created, false otherwise.
   */
  __ensureInstance() {
    let parentNode = wrap(this).parentNode;
    if (!this.__hasInstance()) {
      // Guard against element being detached while render was queued
      if (!parentNode) {
        return false;
      }
      // Find the template (when false, there was no template yet)
      if (!this.__ensureTemplate()) {
        return false;
      }
      this.__createAndInsertInstance(parentNode);
    } else {
      // Move instance children if necessary
      let children = this.__getInstanceNodes();
      if (children && children.length) {
        // Detect case where dom-if was re-attached in new position
        let lastChild = wrap(this).previousSibling;
        if (lastChild !== children[children.length-1]) {
          for (let i=0, n; (i<children.length) && (n=children[i]); i++) {
            wrap(parentNode).insertBefore(n, this);
          }
        }
      }
    }
    return true;
  }

  /**
   * Forces the element to render its content. Normally rendering is
   * asynchronous to a provoking change. This is done for efficiency so
   * that multiple changes trigger only a single render. The render method
   * should be called if, for example, template rendering is required to
   * validate application state.
   *
   * @return {void}
   */
  render() {
    flush();
  }

  /**
   * Performs the key rendering steps:
   * 1. Ensure a template instance has been stamped (when true)
   * 2. Remove the template instance (when false and restamp:true)
   * 3. Sync the hidden state of the instance nodes with the if/restamp state
   * 4. Fires the `dom-change` event when necessary
   *
   * @return {void}
   */
  __render() {
    if (this.if) {
      if (!this.__ensureInstance()) {
        // No template found yet
        return;
      }
    } else if (this.restamp) {
      this.__teardownInstance();
    }
    this._showHideChildren();
    if ((!suppressTemplateNotifications || this.notifyDomChange)
        && this.if != this._lastIf) {
      this.dispatchEvent(new CustomEvent('dom-change', {
        bubbles: true,
        composed: true
      }));
      this._lastIf = this.if;
    }
  }

  // Ideally these would be annotated as abstract methods in an abstract class,
  // but closure compiler is finnicky
  /* eslint-disable valid-jsdoc */
  /**
   * Abstract API to be implemented by subclass: Returns true if a template
   * instance has been created and inserted.
   *
   * @protected
   * @return {boolean} True when an instance has been created.
   */
  __hasInstance() { }

  /**
   * Abstract API to be implemented by subclass: Returns the child nodes stamped
   * from a template instance.
   *
   * @protected
   * @return {Array<Node>} Array of child nodes stamped from the template
   * instance.
   */
  __getInstanceNodes() { }

  /**
   * Abstract API to be implemented by subclass: Creates an instance of the
   * template and inserts it into the given parent node.
   *
   * @protected
   * @param {Node} parentNode The parent node to insert the instance into
   * @return {void}
   */
  __createAndInsertInstance(parentNode) { } // eslint-disable-line no-unused-vars

  /**
   * Abstract API to be implemented by subclass: Removes nodes created by an
   * instance of a template and any associated cleanup.
   *
   * @protected
   * @return {void}
   */
  __teardownInstance() { }

  /**
   * Abstract API to be implemented by subclass: Shows or hides any template
   * instance childNodes based on the `if` state of the element and its
   * `__hideTemplateChildren__` property.
   *
   * @protected
   * @return {void}
   */
  _showHideChildren() { }
  /* eslint-enable valid-jsdoc */
}

/**
 * The version of DomIf used when `fastDomIf` setting is in use, which is
 * optimized for first-render (but adds a tax to all subsequent property updates
 * on the host, whether they were used in a given `dom-if` or not).
 *
 * This implementation avoids use of `Templatizer`, which introduces a new scope
 * (a non-element PropertyEffects instance), which is not strictly necessary
 * since `dom-if` never introduces new properties to its scope (unlike
 * `dom-repeat`). Taking advantage of this fact, the `dom-if` reaches up to its
 * `__dataHost` and stamps the template directly from the host using the host's
 * runtime `_stampTemplate` API, which binds the property effects of the
 * template directly to the host. This both avoids the intermediary
 * `Templatizer` instance, but also avoids the need to bind host properties to
 * the `<template>` element and forward those into the template instance.
 *
 * In this version of `dom-if`, the `this.__instance` method is the
 * `DocumentFragment` returned from `_stampTemplate`, which also serves as the
 * handle for later removing it using the `_removeBoundDom` method.
 */
class DomIfFast extends DomIfBase {

  constructor() {
    super();
    this.__instance = null;
    this.__syncInfo = null;
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * @override
   * @return {boolean} True when an instance has been created.
   */
  __hasInstance() {
    return Boolean(this.__instance);
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * @override
   * @return {Array<Node>} Array of child nodes stamped from the template
   * instance.
   */
  __getInstanceNodes() {
    return this.__instance.templateInfo.childNodes;
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * Stamps the template by calling `_stampTemplate` on the `__dataHost` of this
   * element and then inserts the resulting nodes into the given `parentNode`.
   *
   * @override
   * @param {Node} parentNode The parent node to insert the instance into
   * @return {void}
   */
  __createAndInsertInstance(parentNode) {
    const host = this.__dataHost || this;
    if (strictTemplatePolicy) {
      if (!this.__dataHost) {
        throw new Error('strictTemplatePolicy: template owner not trusted');
      }
    }
    // Pre-bind and link the template into the effects system
    const templateInfo = host._bindTemplate(
        /** @type {!HTMLTemplateElement} */ (this.__template), true);
    // Install runEffects hook that prevents running property effects
    // (and any nested template effects) when the `if` is false
    templateInfo.runEffects = (runEffects, changedProps, hasPaths) => {
      let syncInfo = this.__syncInfo;
      if (this.if) {
        // Mix any props that changed while the `if` was false into `changedProps`
        if (syncInfo) {
          // If there were properties received while the `if` was false, it is
          // important to sync the hidden state with the element _first_, so that
          // new bindings to e.g. `textContent` do not get stomped on by
          // pre-hidden values if `_showHideChildren` were to be called later at
          // the next render. Clearing `__invalidProps` here ensures
          // `_showHideChildren`'s call to `__syncHostProperties` no-ops, so
          // that we don't call `runEffects` more often than necessary.
          this.__syncInfo = null;
          this._showHideChildren();
          changedProps = Object.assign(syncInfo.changedProps, changedProps);
        }
        runEffects(changedProps, hasPaths);
      } else {
        // Accumulate any values changed while `if` was false, along with the
        // runEffects method to sync them, so that we can replay them once `if`
        // becomes true
        if (this.__instance) {
          if (!syncInfo) {
            syncInfo = this.__syncInfo = { runEffects, changedProps: {} };
          }
          if (hasPaths) {
            // Store root object of any paths; this will ensure direct bindings
            // like [[obj.foo]] bindings run after a `set('obj.foo', v)`, but
            // note that path notifications like `set('obj.foo.bar', v)` will
            // not propagate. Since batched path notifications are not
            // supported, we cannot simply accumulate path notifications. This
            // is equivalent to the non-fastDomIf case, which stores root(p) in
            // __invalidProps.
            for (const p in changedProps) {
              const rootProp = root(p);
              syncInfo.changedProps[rootProp] = this.__dataHost[rootProp];
            }
          } else {
            Object.assign(syncInfo.changedProps, changedProps);
          }
        }
      }
    };
    // Stamp the template, and set its DocumentFragment to the "instance"
    this.__instance = host._stampTemplate(
        /** @type {!HTMLTemplateElement} */ (this.__template), templateInfo);
    wrap(parentNode).insertBefore(this.__instance, this);
  }

  /**
   * Run effects for any properties that changed while the `if` was false.
   *
   * @return {void}
   */
  __syncHostProperties() {
    const syncInfo = this.__syncInfo;
    if (syncInfo) {
      this.__syncInfo = null;
      syncInfo.runEffects(syncInfo.changedProps, false);
    }
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * Remove the instance and any nodes it created.  Uses the `__dataHost`'s
   * runtime `_removeBoundDom` method.
   *
   * @override
   * @return {void}
   */
  __teardownInstance() {
    const host = this.__dataHost || this;
    if (this.__instance) {
      host._removeBoundDom(this.__instance);
      this.__instance = null;
      this.__syncInfo = null;
    }
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * Shows or hides the template instance top level child nodes. For
   * text nodes, `textContent` is removed while "hidden" and replaced when
   * "shown."
   *
   * @override
   * @return {void}
   * @protected
   * @suppress {visibility}
   */
  _showHideChildren() {
    const hidden = this.__hideTemplateChildren__ || !this.if;
    if (this.__instance && Boolean(this.__instance.__hidden) !== hidden) {
      this.__instance.__hidden = hidden;
      showHideChildren(hidden, this.__instance.templateInfo.childNodes);
    }
    if (!hidden) {
      this.__syncHostProperties();
    }
  }
}

/**
 * The "legacy" implementation of `dom-if`, implemented using `Templatizer`.
 *
 * In this version, `this.__instance` is the `TemplateInstance` returned
 * from the templatized constructor.
 */
class DomIfLegacy extends DomIfBase {

  constructor() {
    super();
    this.__ctor = null;
    this.__instance = null;
    this.__invalidProps = null;
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * @override
   * @return {boolean} True when an instance has been created.
   */
  __hasInstance() {
    return Boolean(this.__instance);
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * @override
   * @return {Array<Node>} Array of child nodes stamped from the template
   * instance.
   */
  __getInstanceNodes() {
    return this.__instance.children;
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * Stamps the template by creating a new instance of the templatized
   * constructor (which is created lazily if it does not yet exist), and then
   * inserts its resulting `root` doc fragment into the given `parentNode`.
   *
   * @override
   * @param {Node} parentNode The parent node to insert the instance into
   * @return {void}
   */
  __createAndInsertInstance(parentNode) {
    // Ensure we have an instance constructor
    if (!this.__ctor) {
      this.__ctor = templatize(
          /** @type {!HTMLTemplateElement} */ (this.__template), this, {
            // dom-if templatizer instances require `mutable: true`, as
            // `__syncHostProperties` relies on that behavior to sync objects
            mutableData: true,
            /**
             * @param {string} prop Property to forward
             * @param {*} value Value of property
             * @this {DomIfLegacy}
             */
            forwardHostProp: function(prop, value) {
              if (this.__instance) {
                if (this.if) {
                  this.__instance.forwardHostProp(prop, value);
                } else {
                  // If we have an instance but are squelching host property
                  // forwarding due to if being false, note the invalidated
                  // properties so `__syncHostProperties` can sync them the next
                  // time `if` becomes true
                  this.__invalidProps =
                      this.__invalidProps || Object.create(null);
                  this.__invalidProps[root(prop)] = true;
                }
              }
            }
          });
    }
    // Create and insert the instance
    this.__instance = new this.__ctor();
    wrap(parentNode).insertBefore(this.__instance.root, this);
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * Removes the instance and any nodes it created.
   *
   * @override
   * @return {void}
   */
  __teardownInstance() {
    if (this.__instance) {
      let c$ = this.__instance.children;
      if (c$ && c$.length) {
        // use first child parent, for case when dom-if may have been detached
        let parent = wrap(c$[0]).parentNode;
        // Instance children may be disconnected from parents when dom-if
        // detaches if a tree was innerHTML'ed
        if (parent) {
          parent = wrap(parent);
          for (let i=0, n; (i<c$.length) && (n=c$[i]); i++) {
            parent.removeChild(n);
          }
        }
      }
      this.__invalidProps = null;
      this.__instance = null;
    }
  }

  /**
   * Forwards any properties that changed while the `if` was false into the
   * template instance and flushes it.
   *
   * @return {void}
   */
  __syncHostProperties() {
    let props = this.__invalidProps;
    if (props) {
      this.__invalidProps = null;
      for (let prop in props) {
        this.__instance._setPendingProperty(prop, this.__dataHost[prop]);
      }
      this.__instance._flushProperties();
    }
  }

  /**
   * Implementation of abstract API needed by DomIfBase.
   *
   * Shows or hides the template instance top level child elements. For
   * text nodes, `textContent` is removed while "hidden" and replaced when
   * "shown."
   *
   * @override
   * @protected
   * @return {void}
   * @suppress {visibility}
   */
  _showHideChildren() {
    const hidden = this.__hideTemplateChildren__ || !this.if;
    if (this.__instance && Boolean(this.__instance.__hidden) !== hidden) {
      this.__instance.__hidden = hidden;
      this.__instance._showHideChildren(hidden);
    }
    if (!hidden) {
      this.__syncHostProperties();
    }
  }
}

/**
 * The `<dom-if>` element will stamp a light-dom `<template>` child when
 * the `if` property becomes truthy, and the template can use Polymer
 * data-binding and declarative event features when used in the context of
 * a Polymer element's template.
 *
 * When `if` becomes falsy, the stamped content is hidden but not
 * removed from dom. When `if` subsequently becomes truthy again, the content
 * is simply re-shown. This approach is used due to its favorable performance
 * characteristics: the expense of creating template content is paid only
 * once and lazily.
 *
 * Set the `restamp` property to true to force the stamped content to be
 * created / destroyed when the `if` condition changes.
 *
 * @customElement
 * @polymer
 * @extends DomIfBase
 * @constructor
 * @summary Custom element that conditionally stamps and hides or removes
 *   template content based on a boolean flag.
 */
export const DomIf = fastDomIf ? DomIfFast : DomIfLegacy;

customElements.define(DomIf.is, DomIf);
