/*
 Floorplan for Home Assistant
 Version: 1.0.7.57
 By Petar Kozul
 https://github.com/pkozul/ha-floorplan
*/

'use strict';

(function () {
  if (typeof window.Floorplan === 'function') {
    return;
  }

  class Floorplan {
    constructor() {
      this.version = '1.0.7.57';
      this.doc = {};
      this.hass = {};
      this.openMoreInfo = () => { };
      this.setIsLoading = () => { };
      this.config = {};
      this.timeDifference = undefined;
      this.pageInfos = [];
      this.entityInfos = [];
      this.elementInfos = [];
      this.cssRules = [];
      this.entityTransitions = {};
      this.lastMotionConfig = {};
      this.logLevels = [];
      this.handleEntitiesDebounced = {};
      this.variables = [];

      //this.setIsLoading(true);
    }

    hassChanged(newHass, oldHass) {
      this.hass = newHass;

      if (!this.config) {
        return;
      }

      this.handleEntitiesDebounced(); // use debounced wrapper
    }

    /***************************************************************************************************************************/
    /* Startup
    /***************************************************************************************************************************/

    init(options) {
      this.doc = options.doc;
      this.hass = options.hass;
      this.openMoreInfo = options.openMoreInfo;
      this.setIsLoading = options.setIsLoading;

      window.onerror = this.handleWindowError.bind(this);

      this.handleEntitiesDebounced = this.debounce(() => {
        return this.handleEntities();
      }, 100);

      this.initTimeDifference();

      return this.loadConfig(options.config)
        .then(config => {
          this.config = config;

          this.getLogLevels();
          this.logInfo('VERSION', `Floorplan v${this.version}`);

          if (!this.validateConfig(this.config)) {
            this.setIsLoading(false);
            return Promise.resolve();
          }

          return this.loadLibraries()
            .then(() => {
              this.initFullyKiosk();
              return this.config.pages ? this.initMultiPage() : this.initSinglePage();
            });
        })
        .catch(error => {
          this.setIsLoading(false);
          this.handleError(error);
        });
    }

    initMultiPage() {
      return this.loadPages()
        .then(() => {
          this.setIsLoading(false);
          this.initPageDisplay();
          this.initVariables();
          this.initStartupActions();
          return this.handleEntities(true);
        });
    }

    initSinglePage() {
      let imageUrl = this.getBestImage(this.config);
      return this.loadFloorplanSvg(imageUrl)
        .then((svg) => {
          this.config.svg = svg;
          return this.loadStyleSheet(this.config.stylesheet)
            .then(() => {
              return this.initFloorplan(svg, this.config)
                .then(() => {
                  this.setIsLoading(false);
                  this.initPageDisplay();
                  this.initVariables();
                  this.initStartupActions();
                  return this.handleEntities(true);
                })
            });
        });
    }

    getLogLevels() {
      if (!this.config.log_level) {
        return;
      }

      let allLogLevels = {
        error: ['error'],
        warning: ['error', 'warning'],
        info: ['error', 'warning', 'info'],
        debug: ['error', 'warning', 'info', 'debug'],
      };

      this.logLevels = allLogLevels[this.config.log_level.toLowerCase()];
    }

    /***************************************************************************************************************************/
    /* Loading resources
    /***************************************************************************************************************************/

    loadConfig(configUrl) {
      return this.fetchTextResource(configUrl, false)
        .then(config => {
          return Promise.resolve(YAML.parse(config));
        });
    }

    loadLibraries() {
      let promises = [];

      if (this.isOptionEnabled(this.config.pan_zoom)) {
        promises.push(this.loadScript('/local/custom_ui/floorplan/lib/svg-pan-zoom.min.js'));
      }

      if (this.isOptionEnabled(this.config.fully_kiosk)) {
        promises.push(this.loadScript('/local/custom_ui/floorplan/lib/fully-kiosk.js'));
      }

      return promises.length ? Promise.all(promises) : Promise.resolve();
    }

    loadScript(scriptUrl) {
      return new Promise((resolve, reject) => {
        let script = document.createElement('script');
        script.src = this.cacheBuster(scriptUrl);
        script.onload = () => {
          return resolve();
        };
        script.onerror = (err) => {
          reject(new URIError(`${err.target.src}`));
        };

        this.doc.appendChild(script);
      });
    }

    loadPages() {
      let configPromises = [Promise.resolve()]
        .concat(this.config.pages.map(pageConfigUrl => {
          return this.loadPageConfig(pageConfigUrl, this.config.pages.indexOf(pageConfigUrl));
        }));

      return Promise.all(configPromises)
        .then(() => {
          let pageInfos = Object.keys(this.pageInfos).map(key => this.pageInfos[key]);
          pageInfos.sort((a, b) => a.index - b.index); // sort ascending

          let masterPageInfo = pageInfos.find(pageInfo => pageInfo.config.master_page);
          if (masterPageInfo) {
            masterPageInfo.isMaster = true;
          }

          let defaultPageInfo = pageInfos.find(pageInfo => !pageInfo.config.master_page);
          if (defaultPageInfo) {
            defaultPageInfo.isDefault = true;
          }

          let svgPromises = [Promise.resolve()]
            .concat(pageInfos.map(pageInfo => this.loadPageFloorplanSvg(pageInfo, masterPageInfo)));

          return Promise.all(svgPromises);
        });
    }

    loadPageConfig(pageConfigUrl, index) {
      return this.loadConfig(pageConfigUrl)
        .then((pageConfig) => {
          let pageInfo = this.createPageInfo(pageConfig);
          pageInfo.index = index;
          return Promise.resolve(pageInfo);
        });
    }

    loadPageFloorplanSvg(pageInfo, masterPageInfo) {
      let imageUrl = this.getBestImage(pageInfo.config);
      return this.loadFloorplanSvg(imageUrl, pageInfo, masterPageInfo)
        .then((svg) => {
          svg.id = pageInfo.config.page_id; // give the SVG an ID so it can be styled (i.e. background color)
          pageInfo.svg = svg;
          return this.loadStyleSheet(pageInfo.config.stylesheet)
            .then(() => {
              return this.initFloorplan(pageInfo.svg, pageInfo.config);
            });
        });
    }

    getBestImage(config) {
      let imageUrl = '';

      if (typeof config.image === 'string') {
        imageUrl = config.image;
      }
      else {
        if (config.image.sizes) {
          config.image.sizes.sort((a, b) => b.min_width - a.min_width); // sort descending
          for (let pageSize of config.image.sizes) {
            if (screen.width >= pageSize.min_width) {
              imageUrl = pageSize.location;
              break;
            }
          }
        }
      }

      return imageUrl;
    }

    createPageInfo(pageConfig) {
      let pageInfo = { config: pageConfig };

      // Merge the page's rules with the main config's rules
      if (pageInfo.config.rules && this.config.rules) {
        pageInfo.config.rules = pageInfo.config.rules.concat(this.config.rules);
      }

      this.pageInfos[pageInfo.config.page_id] = pageInfo;

      return pageInfo;
    }

    loadStyleSheet(stylesheetUrl) {
      if (!stylesheetUrl) {
        return Promise.resolve();
      }

      return this.fetchTextResource(stylesheetUrl, false)
        .then(stylesheet => {
          let link = document.createElement('style');
          link.type = 'text/css';
          link.innerHTML = stylesheet;
          this.doc.appendChild(link);

          let cssRules = this.getArray(link.sheet.cssRules);
          this.cssRules = this.cssRules.concat(cssRules);

          return Promise.resolve();
        });
    }

    loadFloorplanSvg(imageUrl, pageInfo, masterPageInfo) {
      return this.fetchTextResource(imageUrl, true)
        .then(result => {
          let svg = $(result).siblings('svg')[0];
          svg = svg ? svg : $(result);

          if (pageInfo) {
            $(svg).attr('id', pageInfo.config.page_id);
          }

          $(svg).height('100%');
          $(svg).width('100%');
          $(svg).css('position', this.isPanel ? 'absolute' : 'relative');
          $(svg).css('cursor', 'default');
          $(svg).css('opacity', 0);
          $(svg).attr('xmlns:xlink', 'http://www.w3.org/1999/xlink');

          if (pageInfo && masterPageInfo) {
            let masterPageId = masterPageInfo.config.page_id;
            let contentElementId = masterPageInfo.config.master_page.content_element;

            if (pageInfo.config.page_id === masterPageId) {
              $(this.doc).find('#floorplan').append(svg);
            }
            else {
              let $masterPageElement = $(this.doc).find('#' + masterPageId);
              let $contentElement = $(this.doc).find('#' + contentElementId);

              let height = Number.parseFloat($(svg).attr('height'));
              let width = Number.parseFloat($(svg).attr('width'));
              if (!$(svg).attr('viewBox')) {
                $(svg).attr('viewBox', `0 0 ${width} ${height}`);
              }

              $(svg)
                .attr('preserveAspectRatio', 'xMinYMin meet')
                .attr('height', $contentElement.attr('height'))
                .attr('width', $contentElement.attr('width'))
                .attr('x', $contentElement.attr('x'))
                .attr('y', $contentElement.attr('y'));

              $contentElement.parent().append(svg);
            }
          }
          else {
            $(this.doc).find('#floorplan').append(svg);
          }

          // Enable pan / zoom if enabled in config
          if (this.isOptionEnabled(this.config.pan_zoom)) {
            svgPanZoom($(svg)[0], {
              zoomEnabled: true,
              controlIconsEnabled: true,
              fit: true,
              center: true,
            });
          }

          return Promise.resolve(svg);
        });
    }

    loadImage(imageUrl, svgElementInfo, entityId, rule) {
      if (imageUrl.toLowerCase().indexOf('.svg') >= 0) {
        return this.loadSvgImage(imageUrl, svgElementInfo, entityId, rule);
      }
      else {
        return this.loadBitmapImage(imageUrl, svgElementInfo, entityId, rule);
      }
    }

    loadBitmapImage(imageUrl, svgElementInfo, entityId, rule) {
      return this.fetchImageResource(imageUrl, false, true)
        .then(imageData => {
          this.logDebug('IMAGE', `${entityId} (setting image: ${imageUrl})`);

          let svgElement = svgElementInfo.svgElement; // assume the target element already exists

          if (!$(svgElement).is('image')) {
            svgElement = this.createImageElement(svgElementInfo.originalSvgElement);

            $(svgElement).append(document.createElementNS('http://www.w3.org/2000/svg', 'title'))
              .off('click')
              .on('click', this.onEntityClick.bind({ instance: this, svgElementInfo: svgElementInfo, entityId: entityId, rule: rule }))
              .css('cursor', 'pointer')
              .addClass('ha-entity');

            svgElementInfo.svgElement = this.replaceElement(svgElementInfo.svgElement, svgElement);
          }

          let existingHref = svgElement.getAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href');
          if (existingHref !== imageData) {
            svgElement.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', imageUrl);
          }

          return Promise.resolve(svgElement);
        });
    }

    loadSvgImage(imageUrl, svgElementInfo, entityId, rule) {
      return this.fetchTextResource(imageUrl, true)
        .then(result => {
          this.logDebug('IMAGE', `${entityId} (setting image: ${imageUrl})`);

          let svgElement = $(result).siblings('svg')[0];
          svgElement = svgElement ? svgElement : $(result);

          let height = Number.parseFloat($(svgElement).attr('height'));
          let width = Number.parseFloat($(svgElement).attr('width'));
          if (!$(svgElement).attr('viewBox')) {
            $(svgElement).attr('viewBox', `0 0 ${width} ${height}`);
          }

          $(svgElement)
            .attr('id', svgElementInfo.svgElement.id)
            .attr('preserveAspectRatio', 'xMinYMin meet')
            .attr('height', svgElementInfo.originalBBox.height)
            .attr('width', svgElementInfo.originalBBox.width)
            .attr('x', svgElementInfo.originalBBox.x)
            .attr('y', svgElementInfo.originalBBox.y);

          $(svgElement).find('*').append(document.createElementNS('http://www.w3.org/2000/svg', 'title'))
            .off('click')
            .on('click', this.onEntityClick.bind({ instance: this, svgElementInfo: svgElementInfo, entityId: entityId, rule: rule }))
            .css('cursor', 'pointer')
            .addClass('ha-entity');

          svgElementInfo.svgElement = this.replaceElement(svgElementInfo.svgElement, svgElement);

          return Promise.resolve(svgElement);
        })
    }

    replaceElement(prevousSvgElement, svgElement) {
      let $parent = $(prevousSvgElement).parent();

      $(prevousSvgElement).find('*')
        .off('click');

      $(prevousSvgElement)
        .off('click')
        .remove();

      $parent.append(svgElement);

      return svgElement;
    }

    /***************************************************************************************************************************/
    /* Initialization
    /***************************************************************************************************************************/

    initTimeDifference() {
      this.hass.connection.socket.addEventListener('message', event => {
        let data = JSON.parse(event.data);

        // Store the time difference between the local web browser and the Home Assistant server
        if (data.event && data.event.time_fired) {
          let lastEventFiredTime = moment(data.event.time_fired).toDate();
          this.timeDifference = moment().diff(moment(lastEventFiredTime), 'milliseconds');
        }
      });
    }

    initFullyKiosk() {
      if (this.isOptionEnabled(this.config.fully_kiosk)) {
        this.fullyKiosk = new FullyKiosk(this);
        this.fullyKiosk.init();
      }
    }

    initPageDisplay() {
      if (this.config.pages) {
        Object.keys(this.pageInfos).map(key => {
          let pageInfo = this.pageInfos[key];

          $(pageInfo.svg).css('opacity', 1);
          $(pageInfo.svg).css('display', pageInfo.isMaster || pageInfo.isDefault ? 'initial' : 'none'); // Show the first page
        });
      }
      else {
        // Show the SVG
        $(this.config.svg).css('opacity', 1);
        $(this.config.svg).css('display', 'initial');
      }
    }

    initVariables() {
      if (this.config.variables) {
        for (let variable of this.config.variables) {
          this.initVariable(variable);
        }
      }

      if (this.config.pages) {
        for (let key of Object.keys(this.pageInfos)) {
          let pageInfo = this.pageInfos[key];

          if (pageInfo.config.variables) {
            for (let variable of pageInfo.config.variables) {
              this.initVariable(variable);
            }
          }
        }
      }
    }

    initVariable(variable) {
      let variableName;
      let value;

      if (typeof variable === 'string') {
        variableName = variable;
      }
      else {
        variableName = variable.name;

        value = variable.value;
        if (variable.value_template) {
          value = this.evaluate(variable.value_template, variableName, undefined);
        }
      }

      if (!this.entityInfos[variableName]) {
        let entityInfo = { entityId: variableName, ruleInfos: [], lastState: undefined };
        this.entityInfos[variableName] = entityInfo;
      }

      if (!this.hass.states[variableName]) {
        this.hass.states[variableName] = {
          entity_id: variableName,
          state: value,
          last_changed: new Date(),
          attributes: [],
        };
      }

      this.setVariable(variableName, value, [], true);
    }

    initStartupActions() {
      let actions = [];

      let startup = this.config.startup;
      if (startup && startup.action) {
        actions = actions.concat(Array.isArray(startup.action) ? startup.action : [startup.action]);
      }

      if (this.config.pages) {
        for (let key of Object.keys(this.pageInfos)) {
          let pageInfo = this.pageInfos[key];

          let startup = pageInfo.config.startup;
          if (startup && startup.action) {
            actions = actions.concat(Array.isArray(startup.action) ? startup.action : [startup.action]);
          }
        }
      }

      for (let action of actions) {
        if (action.service || action.service_template) {
          let actionService = this.getActionService(action, undefined, undefined);

          switch (this.getDomain(actionService)) {
            case 'floorplan':
              this.callFloorplanService(action, undefined, undefined);
              break;

            default:
              this.callHomeAssistantService(action, undefined, undefined);
              break;
          }
        }
      }
    }

    /***************************************************************************************************************************/
    /* SVG initialization
    /***************************************************************************************************************************/

    initFloorplan(svg, config) {
      if (!config.rules) {
        return Promise.resolve();;
      }

      let svgElements = $(svg).find('*').toArray();

      this.initLastMotion(config, svg, svgElements);
      this.initRules(config, svg, svgElements);

      return Promise.resolve();;
    }

    initLastMotion(config, svg, svgElements) {
      // Add the last motion entity if required
      if (config.last_motion && config.last_motion.entity && config.last_motion.class) {
        this.lastMotionConfig = config.last_motion;

        let entityInfo = { entityId: config.last_motion.entity, ruleInfos: [], lastState: undefined };
        this.entityInfos[config.last_motion.entity] = entityInfo;
      }
    }

    initRules(config, svg, svgElements) {
      // Apply default options to rules that don't override the options explictly
      if (config.defaults) {
        for (let rule of config.rules) {
          rule.hover_over = (rule.hover_over === undefined) ? config.defaults.hover_over : rule.hover_over;
          rule.more_info = (rule.more_info === undefined) ? config.defaults.more_info : rule.more_info;
        }
      }

      for (let rule of config.rules) {
        if (rule.entity || rule.entities) {
          this.initEntityRule(rule, svg, svgElements);
        }
        else if (rule.element || rule.elements) {
          this.initElementRule(rule, svg, svgElements);
        }
      }
    }

    initEntityRule(rule, svg, svgElements) {
      let entities = this.initGetEntityRuleEntities(rule);
      for (let entity of entities) {
        let entityId = entity.entityId;
        let elementId = entity.elementId;

        let entityInfo = this.entityInfos[entityId];
        if (!entityInfo) {
          entityInfo = { entityId: entityId, ruleInfos: [], lastState: undefined };
          this.entityInfos[entityId] = entityInfo;
        }

        let ruleInfo = { rule: rule, svgElementInfos: {}, };
        entityInfo.ruleInfos.push(ruleInfo);

        let svgElement = svgElements.find(svgElement => svgElement.id === elementId);
        if (!svgElement) {
          this.logWarning('CONFIG', `Cannot find element '${elementId}' in SVG file`);
          continue;
        }

        let svgElementInfo = this.addSvgElementToRule(svg, svgElement, ruleInfo);

        let $svgElement = $(svgElementInfo.svgElement);
        if ($svgElement.length) {
          svgElementInfo.svgElement = $svgElement[0];

          // Create a title element (to support hover over text)
          $svgElement.append(document.createElementNS('http://www.w3.org/2000/svg', 'title'));

          $svgElement.off('click').on('click', this.onEntityClick.bind({ instance: this, svgElementInfo: svgElementInfo, entityId: entityId, rule: ruleInfo.rule }));
          $svgElement.css('cursor', 'pointer');
          $svgElement.addClass('ha-entity');

          if ($svgElement.is('text') && ($svgElement[0].id === elementId)) {
            let backgroundSvgElement = svgElements.find(svgElement => svgElement.id === ($svgElement[0].id + '.background'));
            if (!backgroundSvgElement) {
              this.addBackgroundRectToText(svgElementInfo);
            }
            else {
              svgElementInfo.alreadyHadBackground = true;
              $(backgroundSvgElement).css('fill-opacity', 0);
            }
          }
        }
      }
    }

    initGetEntityRuleEntities(rule) {
      let targetEntities = [];

      // Split out HA entity groups into separate entities
      if (rule.groups) {
        for (let entityId of rule.groups) {
          let group = this.hass.states[entityId];
          if (group) {
            for (let entityId of group.attributes.entity_id) {
              targetEntities.push({ entityId: entityId, elementId: entityId });
            }
          }
          else {
            this.logWarning('CONFIG', `Cannot find '${entityId}' in Home Assistant groups`);
          }
        }
      }

      // HA entity treated as is
      if (rule.entity) {
        rule.entities = [rule.entity];
      }

      // HA entities treated as is
      if (rule.entities) {
        let entityIds = rule.entities.filter(x => (typeof x === 'string'));
        for (let entityId of entityIds) {
          let entity = this.hass.states[entityId];
          let isFloorplanVariable = (entityId.split('.')[0] === 'floorplan');

          if (entity || isFloorplanVariable) {
            let elementId = rule.element ? rule.element : entityId;
            targetEntities.push({ entityId: entityId, elementId: elementId });
          }
          else {
            this.logWarning('CONFIG', `Cannot find '${entityId}' in Home Assistant entities`);
          }
        }

        let entityObjects = rule.entities.filter(x => (typeof x !== 'string'));
        for (let entityObject of entityObjects) {
          let entity = this.hass.states[entityObject.entity];
          let isFloorplanVariable = (entityObject.entity.split('.')[0] === 'floorplan');

          if (entity || isFloorplanVariable) {
            targetEntities.push({ entityId: entityObject.entity, elementId: entityObject.element });
          }
          else {
            this.logWarning('CONFIG', `Cannot find '${entityObject.entity}' in Home Assistant entities`);
          }
        }
      }

      return targetEntities;
    }

    initElementRule(rule, svg, svgElements) {
      if (rule.element) {
        rule.elements = [rule.element];
      }

      for (let elementId of rule.elements) {
        let svgElement = svgElements.find(svgElement => svgElement.id === elementId);
        if (svgElement) {
          let elementInfo = this.elementInfos[elementId];
          if (!elementInfo) {
            elementInfo = { ruleInfos: [], lastState: undefined };
            this.elementInfos[elementId] = elementInfo;
          }

          let ruleInfo = { rule: rule, svgElementInfos: {}, };
          elementInfo.ruleInfos.push(ruleInfo);

          let svgElementInfo = this.addSvgElementToRule(svg, svgElement, ruleInfo);

          let $svgElement = $(svgElementInfo.svgElement);

          $svgElement.off('click').on('click', this.onElementClick.bind({ instance: this, svgElementInfo: svgElementInfo, elementId: elementId, rule: rule }));
          $svgElement.css('cursor', 'pointer');

          if ($svgElement.is('text') && ($svgElement[0].id === elementId)) {
            let backgroundSvgElement = svgElements.find(svgElement => svgElement.id === ($svgElement[0].id + '.background'));
            if (!backgroundSvgElement) {
              this.addBackgroundRectToText(svgElementInfo);
            }
            else {
              svgElementInfo.alreadyHadBackground = true;
              $(backgroundSvgElement).css('fill-opacity', 0);
            }
          }

          let actions = Array.isArray(rule.action) ? rule.action : [rule.action];
          for (let action of actions) {
            if (action) {
              switch (action.service) {
                case 'toggle':
                  for (let otherElementId of action.data.elements) {
                    let otherSvgElement = svgElements.find(svgElement => svgElement.id === otherElementId);
                    $(otherSvgElement).addClass(action.data.default_class);
                  }
                  break;

                default:
                  break;
              }
            }
          }
        }
        else {
          this.logWarning('CONFIG', `Cannot find '${elementId}' in SVG file`);
        }
      }
    }

    addBackgroundRectToText(svgElementInfo) {
      let svgElement = svgElementInfo.svgElement;

      let bbox = svgElement.getBBox();

      let rect = $(document.createElementNS('http://www.w3.org/2000/svg', 'rect'))
        .attr('id', svgElement.id + '.background')
        .attr('height', bbox.height + 1)
        .attr('width', bbox.width + 2)
        .attr('x', bbox.x - 1)
        .attr('y', bbox.y - 0.5)
        .css('fill-opacity', 0);

      $(rect).insertBefore(svgElement);
    }

    addSvgElementToRule(svg, svgElement, ruleInfo) {
      let svgElementInfo = {
        entityId: svgElement.id,
        svg: svg,
        svgElement: svgElement,
        originalSvgElement: svgElement,
        originalStroke: svgElement.style.stroke,
        originalFill: svgElement.style.fill,
        originalClasses: this.getArray(svgElement.classList),
        originalBBox: svgElement.getBBox(),
        originalClientRect: svgElement.getBoundingClientRect(),
      };
      ruleInfo.svgElementInfos[svgElement.id] = svgElementInfo;

      this.addNestedSvgElementsToRule(svgElement, ruleInfo);

      return svgElementInfo;
    }

    addNestedSvgElementsToRule(svgElement, ruleInfo) {
      $(svgElement).find('*').each((i, svgNestedElement) => {
        ruleInfo.svgElementInfos[svgNestedElement.id] = {
          entityId: svgElement.id,
          svgElement: svgNestedElement,
          originalSvgElement: svgNestedElement,
          originalStroke: svgNestedElement.style.stroke,
          originalFill: svgNestedElement.style.fill,
          originalClasses: this.getArray(svgNestedElement.classList),
          //originalBBox: svgNestedElement.getBBox(),
          //originalClientRect: svgNestedElement.getBoundingClientRect(),
        };
      });
    }

    createImageElement(svgElement) {
      return $(document.createElementNS('http://www.w3.org/2000/svg', 'image'))
        .attr('id', $(svgElement).attr('id'))
        .attr('x', $(svgElement).attr('x'))
        .attr('y', $(svgElement).attr('y'))
        .attr('height', $(svgElement).attr('height'))
        .attr('width', $(svgElement).attr('width'))[0];
      /*
              return $('object')
                .attr('type', $(svgElement).attr('image/svg+xml'))
                .attr('id', $(svgElement).attr('id'))
                .attr('x', $(svgElement).attr('x'))
                .attr('y', $(svgElement).attr('y'))
                .attr('height', $(svgElement).attr('height'))
                .attr('width', $(svgElement).attr('width'))[0];
                */
    }

    addClass(entityId, svgElement, className) {
      if ($(svgElement).hasClass('ha-leave-me-alone')) {
        return;
      }

      if (!$(svgElement).hasClass(className)) {
        this.logDebug('CLASS', `${entityId} (adding class: ${className})`);
        $(svgElement).addClass(className);

        if ($(svgElement).is('text')) {
          $(svgElement).parent().find(`[id="${entityId}.background"]`).each((i, rectElement) => {
            if (!$(rectElement).hasClass(className + '-background')) {
              $(rectElement).addClass(className + '-background');
            }
          });
        }
      }

      $(svgElement).find('*').each((i, svgNestedElement) => {
        if (!$(svgNestedElement).hasClass('ha-leave-me-alone')) {
          if (!$(svgNestedElement).hasClass(className)) {
            $(svgNestedElement).addClass(className);
          }
        }
      });
    }

    removeClasses(entityId, svgElement, classes) {
      for (let className of classes) {
        if ($(svgElement).hasClass(className)) {
          this.logDebug('CLASS', `${entityId} (removing class: ${className})`);
          $(svgElement).removeClass(className);

          if ($(svgElement).is('text')) {
            $(svgElement).parent().find(`[id="${entityId}.background"]`).each((i, rectElement) => {
              if ($(rectElement).hasClass(className + '-background')) {
                $(rectElement).removeClass(className + '-background');
              }
            });
          }

          $(svgElement).find('*').each((i, svgNestedElement) => {
            if ($(svgNestedElement).hasClass(className)) {
              $(svgNestedElement).removeClass(className);
            }
          });
        }
      }
    }

    setEntityStyle(svgElementInfo, svgElement, entityInfo, ruleInfo) {
      let stateConfig = ruleInfo.rule.states.find(stateConfig => (stateConfig.state === entityInfo.lastState.state));
      if (stateConfig) {
        let stroke = this.getStroke(stateConfig);
        if (stroke) {
          svgElement.style.stroke = stroke;
        }
        else {
          if (svgElementInfo.originalStroke) {
            svgElement.style.stroke = svgElementInfo.originalStroke;
          }
          else {
            // ???
          }
        }

        let fill = this.getFill(stateConfig);
        if (fill) {
          svgElement.style.fill = fill;
        }
        else {
          if (svgElementInfo.originalFill) {
            svgElement.style.fill = svgElementInfo.originalFill;
          }
          else {
            // ???
          }
        }
      }
    }

    /***************************************************************************************************************************/
    /* Entity handling (when states change)
    /***************************************************************************************************************************/

    handleEntities(isInitialLoad) {
      this.handleElements(isInitialLoad);

      let changedEntityIds = this.getChangedEntities(isInitialLoad);
      changedEntityIds = changedEntityIds.concat(Object.keys(this.variables)); // always assume variables need updating

      if (changedEntityIds && changedEntityIds.length) {
        let promises = changedEntityIds.map(entityId => this.handleEntity(entityId, isInitialLoad));
        return Promise.all(promises)
          .then(() => {
            return Promise.resolve(changedEntityIds);
          });
      }
      else {
        return Promise.resolve();
      }
    }

    getChangedEntities(isInitialLoad) {
      let changedEntityIds = [];

      let entityIds = Object.keys(this.hass.states);

      let lastMotionEntityInfo, oldLastMotionState, newLastMotionState;

      if (this.lastMotionConfig) {
        lastMotionEntityInfo = this.entityInfos[this.lastMotionConfig.entity];
        if (lastMotionEntityInfo && lastMotionEntityInfo.lastState) {
          oldLastMotionState = lastMotionEntityInfo.lastState.state;
          newLastMotionState = this.hass.states[this.lastMotionConfig.entity].state;
        }
      }

      for (let entityId of entityIds) {
        let entityInfo = this.entityInfos[entityId];
        if (entityInfo) {
          let entityState = this.hass.states[entityId];

          if (isInitialLoad) {
            this.logDebug('STATE', `${entityId}: ${entityState.state} (initial load)`);
            if (changedEntityIds.indexOf(entityId) < 0) {
              changedEntityIds.push(entityId);
            }
          }
          else if (entityInfo.lastState) {
            let oldState = entityInfo.lastState.state;
            let newState = entityState.state;

            if (entityState.last_changed !== entityInfo.lastState.last_changed) {
              this.logDebug('STATE', `${entityId}: ${newState} (last changed ${moment(entityInfo.lastState.last_changed).format("DD-MMM-YYYY HH:mm:ss")})`);
              if (changedEntityIds.indexOf(entityId) < 0) {
                changedEntityIds.push(entityId);
              }
            }
            else {
              if (!this.equal(entityInfo.lastState.attributes, entityState.attributes)) {
                this.logDebug('STATE', `${entityId}: attributes (last updated ${moment(entityInfo.lastState.last_changed).format("DD-MMM-YYYY HH:mm:ss")})`);
                if (changedEntityIds.indexOf(entityId) < 0) {
                  changedEntityIds.push(entityId);
                }
              }
            }

            if (this.lastMotionConfig) {
              if ((newLastMotionState !== oldLastMotionState) && (entityId.indexOf('binary_sensor') >= 0)) {
                let friendlyName = entityState.attributes.friendly_name;

                if (friendlyName === newLastMotionState) {
                  this.logDebug('LAST_MOTION', `${entityId} (new)`);
                  if (changedEntityIds.indexOf(entityId) < 0) {
                    changedEntityIds.push(entityId);
                  }
                }
                else if (friendlyName === oldLastMotionState) {
                  this.logDebug('LAST_MOTION', `${entityId} (old)`);
                  if (changedEntityIds.indexOf(entityId) < 0) {
                    changedEntityIds.push(entityId);
                  }
                }
              }
            }
          }
        }
      }

      return changedEntityIds;
    }

    handleEntity(entityId, isInitialLoad) {
      let entityState = this.hass.states[entityId];
      let entityInfo = this.entityInfos[entityId];

      entityInfo.lastState = Object.assign({}, entityState);

      return this.handleEntityUpdateDom(entityInfo)
        .then(() => {
          this.handleEntityUpdateCss(entityInfo, isInitialLoad);
          this.handleEntityUpdateLastMotionCss(entityInfo);
          this.handleEntitySetHoverOver(entityInfo);

          return Promise.resolve();
        });
    }

    handleEntityUpdateDom(entityInfo) {
      let promises = [];

      let entityId = entityInfo.entityId;
      let entityState = this.hass.states[entityId];

      for (let ruleInfo of entityInfo.ruleInfos) {
        for (let svgElementId in ruleInfo.svgElementInfos) {
          let svgElementInfo = ruleInfo.svgElementInfos[svgElementId];

          if ($(svgElementInfo.svgElement).is('text')) {
            this.handleEntityUpdateText(entityId, ruleInfo, svgElementInfo);
          }
          else if (ruleInfo.rule.image || ruleInfo.rule.image_template) {
            promises.push(this.handleEntityUpdateImage(entityId, ruleInfo, svgElementInfo));
          }
        }
      }

      return promises.length ? Promise.all(promises) : Promise.resolve();
    }

    handleElements(isInitialLoad) {
      let promises = [];

      Object.keys(this.elementInfos).map(key => {
        let elementInfo = this.elementInfos[key];
        let promise = this.handleElementUpdateDom(elementInfo)
          .then(() => {
            return this.handleElementUpdateCss(elementInfo, isInitialLoad);
          });

        promises.push(promise);
      });

      return promises.length ? Promise.all(promises) : Promise.resolve();
    }

    handleElementUpdateDom(elementInfo) {
      let promises = [];

      for (let ruleInfo of elementInfo.ruleInfos) {
        for (let svgElementId in ruleInfo.svgElementInfos) {
          let svgElementInfo = ruleInfo.svgElementInfos[svgElementId];

          if ($(svgElementInfo.svgElement).is('text')) {
            this.handleEntityUpdateText(svgElementId, ruleInfo, svgElementInfo);
          }
          else if (ruleInfo.rule.image || ruleInfo.rule.image_template) {
            promises.push(this.handleEntityUpdateImage(svgElementId, ruleInfo, svgElementInfo));
          }
        }
      }

      return promises.length ? Promise.all(promises) : Promise.resolve();
    }

    handleEntityUpdateText(entityId, ruleInfo, svgElementInfo) {
      let svgElement = svgElementInfo.svgElement;
      let state = this.hass.states[entityId] ? this.hass.states[entityId].state : undefined;

      let text = ruleInfo.rule.text_template ? this.evaluate(ruleInfo.rule.text_template, entityId, svgElement) : state;

      let tspan = $(svgElement).find('tspan');
      if (tspan.length) {
        $(tspan).text(text);
      }
      else {
        let title = $(svgElement).find('title');
        $(svgElement).text(text);
        if (title.length) {
          $(svgElement).append(title);
        }
      }

      if (!svgElementInfo.alreadyHadBackground) {
        let rect = $(svgElement).parent().find(`[id="${entityId}.background"]`);
        if (rect.length) {
          if ($(svgElement).css('display') != 'none') {
            let parentSvg = $(svgElement).parents('svg').eq(0);
            if ($(parentSvg).css('display') !== 'none') {
              let bbox = svgElement.getBBox();
              $(rect)
                .attr('x', bbox.x - 1)
                .attr('y', bbox.y - 0.5)
                .attr('height', bbox.height + 1)
                .attr('width', bbox.width + 2)
                .height(bbox.height + 1)
                .width(bbox.width + 2);
            }
          }
        }
      }
    }

    handleEntityUpdateImage(entityId, ruleInfo, svgElementInfo) {
      let svgElement = svgElementInfo.svgElement;

      let imageUrl = ruleInfo.rule.image ? ruleInfo.rule.image : this.evaluate(ruleInfo.rule.image_template, entityId, svgElement);

      if (imageUrl && (ruleInfo.imageUrl !== imageUrl)) {
        ruleInfo.imageUrl = imageUrl;

        if (ruleInfo.imageLoader) {
          clearInterval(ruleInfo.imageLoader); // cancel any previous image loading for this rule
        }

        if (ruleInfo.rule.image_refresh_interval) {
          let refreshInterval = parseInt(ruleInfo.rule.image_refresh_interval);

          ruleInfo.imageLoader = setInterval((imageUrl, svgElement) => {
            this.loadImage(imageUrl, svgElementInfo, entityId, ruleInfo.rule)
              .catch(error => {
                this.handleError(error);
              });
          }, refreshInterval * 1000, imageUrl, svgElement);
        }

        return this.loadImage(imageUrl, svgElementInfo, entityId, ruleInfo.rule)
          .catch(error => {
            this.handleError(error);
          });
      }
      else {
        return Promise.resolve();
      }
    }

    handleEntitySetHoverOver(entityInfo) {
      let entityId = entityInfo.entityId;
      let entityState = this.hass.states[entityId];

      for (let ruleInfo of entityInfo.ruleInfos) {
        if (ruleInfo.rule.hover_over !== false) {
          for (let svgElementId in ruleInfo.svgElementInfos) {
            let svgElementInfo = ruleInfo.svgElementInfos[svgElementId];

            this.handlEntitySetHoverOverText(svgElementInfo.svgElement, entityState);
          }
        }
      }
    }

    handlEntitySetHoverOverText(element, entityState) {
      let dateFormat = this.config.date_format ? this.config.date_format : 'DD-MMM-YYYY';

      $(element).find('title').each((i, titleElement) => {
        let lastChangedElapsed = moment().to(moment(entityState.last_changed));
        let lastChangedDate = moment(entityState.last_changed).format(dateFormat);
        let lastChangedTime = moment(entityState.last_changed).format('HH:mm:ss');

        let lastUpdatedElapsed = moment().to(moment(entityState.last_updated));
        let lastUpdatedDate = moment(entityState.last_updated).format(dateFormat);
        let lastUpdatedTime = moment(entityState.last_updated).format('HH:mm:ss');

        let titleText = `${entityState.attributes.friendly_name}\n`;
        titleText += `State: ${entityState.state}\n\n`;

        Object.keys(entityState.attributes).map(key => {
          titleText += `${key}: ${entityState.attributes[key]}\n`;
        });
        titleText += '\n';

        titleText += `Last changed: ${lastChangedDate} ${lastChangedTime}\n`;
        titleText += `Last updated: ${lastUpdatedDate} ${lastUpdatedTime}`;

        $(titleElement).html(titleText);
      });
    }

    handleElementUpdateCss(elementInfo, isInitialLoad) {
      if (!this.cssRules || !this.cssRules.length) {
        return;
      }

      for (let ruleInfo of elementInfo.ruleInfos) {
        for (let svgElementId in ruleInfo.svgElementInfos) {
          let svgElementInfo = ruleInfo.svgElementInfos[svgElementId];

          this.handleUpdateElementCss(svgElementInfo, ruleInfo);
        }
      }
    }

    handleEntityUpdateCss(entityInfo, isInitialLoad) {
      if (!this.cssRules || !this.cssRules.length) {
        return;
      }

      for (let ruleInfo of entityInfo.ruleInfos) {
        for (let svgElementId in ruleInfo.svgElementInfos) {
          let svgElementInfo = ruleInfo.svgElementInfos[svgElementId];

          if (svgElementInfo.svgElement) { // images may not have been updated yet
            let wasTransitionApplied = this.handleEntityUpdateTransitionCss(entityInfo, ruleInfo, svgElementInfo, isInitialLoad);
            this.handleUpdateCss(entityInfo, svgElementInfo, ruleInfo, wasTransitionApplied);
          }
        }
      }
    }

    handleEntityUpdateTransitionCss(entityInfo, ruleInfo, svgElementInfo, isInitialLoad) {
      let entityId = entityInfo.entityId;
      let entityState = this.hass.states[entityId];
      let svgElement = svgElementInfo.svgElement;

      let wasTransitionApplied = false;

      if (ruleInfo.rule.states && ruleInfo.rule.state_transitions) {
        let transitionConfig = ruleInfo.rule.state_transitions.find(transitionConfig => (transitionConfig.to_state === entityState.state));
        if (transitionConfig && transitionConfig.from_state && transitionConfig.to_state && transitionConfig.duration) {
          let elapsed = Math.max(moment().diff(moment(entityState.last_changed), 'milliseconds'), 0);
          let remaining = (transitionConfig.duration * 1000) - elapsed;

          let fromStateConfig = ruleInfo.rule.states.find(stateConfig => (stateConfig.state === transitionConfig.from_state));
          let toStateConfig = ruleInfo.rule.states.find(stateConfig => (stateConfig.state === transitionConfig.to_state));

          let fromColor = this.getFill(fromStateConfig);
          let toColor = this.getFill(toStateConfig);

          if (fromColor && toColor) {
            if (remaining > 0) {
              let transition = this.entityTransitions[entityId];
              if (!transition) {
                this.logDebug('TRANSITION', `${entityId} (created)`);
                transition = {
                  entityId: entityId,
                  svgElementInfo: svgElementInfo,
                  ruleInfo: ruleInfo,
                  duration: transitionConfig.duration,
                  fromStateConfig: fromStateConfig,
                  toStateConfig: toStateConfig,
                  fromColor: fromColor,
                  toColor: toColor,
                  startMoment: undefined,
                  endMoment: undefined,
                  isActive: false,
                };
                this.entityTransitions[entityId] = transition;
              }

              // Assume the transition starts (or started) when the origin state change occurred
              transition.startMoment = this.serverToLocalMoment(moment(entityState.last_changed));
              transition.endMoment = transition.startMoment.clone();
              transition.endMoment.add(transition.duration, 'seconds');

              // If the transition is not currently running, kick it off
              if (!transition.isActive) {
                // If this state change just occurred, the transition starts as of now
                if (!isInitialLoad) {
                  let nowMoment = moment();
                  transition.startMoment = nowMoment.clone();
                  transition.endMoment = transition.startMoment.clone();
                  transition.endMoment.add(transition.duration, 'seconds');
                }

                this.logDebug('TRANSITION', `${transition.entityId}: (start)`);
                transition.isActive = true;
                this.handleEntityTransition(transition);
              }
              else {
                // If the transition is currently running, it will be extended with latest start / end times
                this.logDebug('TRANSITION', `${transition.entityId} (continue)`);
              }
            }
            else {
              this.setEntityStyle(svgElementInfo, svgElement, entityInfo, ruleInfo);
            }
          }
          else {
            this.setEntityStyle(svgElementInfo, svgElement, entityInfo, ruleInfo);
          }
          wasTransitionApplied = true;
        }
      }

      return wasTransitionApplied;
    }

    handleUpdateCss(entityInfo, svgElementInfo, ruleInfo, wasTransitionApplied) {
      let entityId = entityInfo.entityId;
      let svgElement = svgElementInfo.svgElement;

      let targetClass = undefined;
      let obsoleteClasses = [];

      if (ruleInfo.rule.class_template) {
        targetClass = this.evaluate(ruleInfo.rule.class_template, entityId, svgElement);
      }

      // Get the config for the current state
      if (ruleInfo.rule.states) {
        let entityState = this.hass.states[entityId];

        let stateConfig = ruleInfo.rule.states.find(stateConfig => (stateConfig.state === entityState.state));
        if (stateConfig && stateConfig.class && !wasTransitionApplied) {
          targetClass = stateConfig.class;
        }

        // Remove any other previously-added state classes
        for (let otherStateConfig of ruleInfo.rule.states) {
          if (!stateConfig || (otherStateConfig.state !== stateConfig.state)) {
            if (otherStateConfig.class && (otherStateConfig.class !== targetClass) && (otherStateConfig.class !== 'ha-entity') && $(svgElement).hasClass(otherStateConfig.class)) {
              if (svgElementInfo.originalClasses.indexOf(otherStateConfig.class) < 0) {
                obsoleteClasses.push(otherStateConfig.class);
              }
            }
          }
        }
      }
      else {
        if (svgElement.classList) {
          for (let otherClassName of this.getArray(svgElement.classList)) {
            if ((otherClassName !== targetClass) && (otherClassName !== 'ha-entity')) {
              if (svgElementInfo.originalClasses.indexOf(otherClassName) < 0) {
                obsoleteClasses.push(otherClassName);
              }
            }
          }
        }
      }

      // Remove any obsolete classes from the entity
      if (obsoleteClasses.length) {
        //this.logDebug(`${entityId}: Removing obsolete classes: ${obsoleteClasses.join(', ')}`);
        this.removeClasses(entityId, svgElement, obsoleteClasses);
      }

      // Add the target class to the entity
      if (targetClass && !$(svgElement).hasClass(targetClass)) {
        let hasTransitionConfig = ruleInfo.rule.states && ruleInfo.rule.state_transitions;
        if (hasTransitionConfig && !wasTransitionApplied) {
          let transition = this.entityTransitions[entityId];
          if (transition && transition.isActive) {
            this.logDebug('TRANSITION', `${transition.entityId} (cancel)`);
            transition.isActive = false;
          }
        }

        this.addClass(entityId, svgElement, targetClass);
      }
    }

    handleUpdateElementCss(svgElementInfo, ruleInfo) {
      let entityId = svgElementInfo.entityId;
      let svgElement = svgElementInfo.svgElement;

      let targetClass = undefined;
      if (ruleInfo.rule.class_template) {
        targetClass = this.evaluate(ruleInfo.rule.class_template, entityId, svgElement);
      }

      let obsoleteClasses = [];
      for (let otherClassName of this.getArray(svgElement.classList)) {
        if ((otherClassName !== targetClass) && (otherClassName !== 'ha-entity')) {
          if (svgElementInfo.originalClasses.indexOf(otherClassName) < 0) {
            obsoleteClasses.push(otherClassName);
          }
        }
      }

      // Remove any obsolete classes from the entity
      if (obsoleteClasses.length) {
        this.removeClasses(entityId, svgElement, obsoleteClasses);
      }

      // Add the target class to the entity
      if (targetClass && !$(svgElement).hasClass(targetClass)) {
        this.addClass(entityId, svgElement, targetClass);
      }
    }

    handleEntityTransition(transition) {
      if (!transition.isActive) {
        return;
      }

      let nowMoment = moment();

      let isExpired = (nowMoment >= transition.endMoment);

      let ratio = isExpired ? 1 : (nowMoment - transition.startMoment) / (transition.endMoment - transition.startMoment);
      let color = this.getTransitionColor(transition.fromColor, transition.toColor, ratio);
      //this.logDebug('TRANSITION', `${transition.entityId} (ratio: ${ratio}, element: ${transition.svgElementInfo.svgElement.id}, fill: ${color})`);
      transition.svgElementInfo.svgElement.style.fill = color;

      if (isExpired) {
        transition.isActive = false;
        this.logDebug('TRANSITION', `${transition.entityId} (end)`);
        return;
      }

      setTimeout(() => {
        this.handleEntityTransition(transition);
      }, 100);
    }

    handleEntityUpdateLastMotionCss(entityInfo) {
      if (!this.lastMotionConfig || !this.cssRules || !this.cssRules.length) {
        return;
      }

      let entityId = entityInfo.entityId;
      let entityState = this.hass.states[entityId];

      for (let ruleInfo of entityInfo.ruleInfos) {
        for (let svgElementId in ruleInfo.svgElementInfos) {
          let svgElementInfo = ruleInfo.svgElementInfos[svgElementId];
          let svgElement = svgElementInfo.svgElement;

          if (this.hass.states[this.lastMotionConfig.entity] &&
            (entityState.attributes.friendly_name === this.hass.states[this.lastMotionConfig.entity].state)) {
            if (!$(svgElement).hasClass(this.lastMotionConfig.class)) {
              //this.logDebug(`${entityId}: Adding last motion class '${this.lastMotionConfig.class}'`);
              $(svgElement).addClass(this.lastMotionConfig.class);
            }
          }
          else {
            if ($(svgElement).hasClass(this.lastMotionConfig.class)) {
              //this.logDebug(`${entityId}: Removing last motion class '${this.lastMotionConfig.class}'`);
              $(svgElement).removeClass(this.lastMotionConfig.class);
            }
          }
        }
      }
    }

    /***************************************************************************************************************************/
    /* Floorplan helper functions
    /***************************************************************************************************************************/

    isOptionEnabled(option) {
      return ((option === null) || (option !== undefined));
    }

    isLastMotionEnabled() {
      return this.lastMotionConfig && this.config.last_motion.entity && this.config.last_motion.class;
    }

    validateConfig(config) {
      let isValid = true;

      if (!config.pages && !config.rules) {
        this.setIsLoading(false);
        this.logWarning('CONFIG', `Cannot find 'pages' nor 'rules' in floorplan configuration`);
        isValid = false;
      }
      else {
        if (config.pages) {
          if (!config.pages.length) {
            this.logWarning('CONFIG', `The 'pages' section must contain one or more pages in floorplan configuration`);
            isValid = false;
          }
        }
        else {
          if (!config.rules) {
            this.logWarning('CONFIG', `Cannot find 'rules' in floorplan configuration`);
            isValid = false;
          }

          let invalidRules = config.rules.filter(x => x.entities && x.elements);
          if (invalidRules.length) {
            this.logWarning('CONFIG', `A rule cannot contain both 'entities' and 'elements' in floorplan configuration`);
            isValid = false;
          }

          invalidRules = config.rules.filter(x => !(x.entity || x.entities) && !(x.element || x.elements));
          if (invalidRules.length) {
            this.logWarning('CONFIG', `A rule must contain either 'entities' or 'elements' in floorplan configuration`);
            isValid = false;
          }
        }
      }

      return isValid;
    }

    localToServerMoment(localMoment) {
      let serverMoment = localMoment.clone();
      if (this.timeDifference >= 0)
        serverMoment.subtract(this.timeDifference, 'milliseconds');
      else
        serverMoment.add(Math.abs(this.timeDifference), 'milliseconds');
      return serverMoment;
    }

    serverToLocalMoment(serverMoment) {
      let localMoment = serverMoment.clone();
      if (this.timeDifference >= 0)
        localMoment.add(Math.abs(this.timeDifference), 'milliseconds');
      else
        localMoment.subtract(this.timeDifference, 'milliseconds');
      return localMoment;
    }

    evaluate(code, entityId, svgElement) {
      let entityState = this.hass.states[entityId];
      let functionBody = (code.indexOf('${') >= 0) ? `\`${code}\`;` : code;
      functionBody = (functionBody.indexOf('return') >= 0) ? functionBody : `return ${functionBody};`;
      let func = new Function('entity', 'entities', 'hass', 'config', 'element', functionBody);
      return func(entityState, this.hass.states, this.hass, this.config, svgElement);
    }

    /***************************************************************************************************************************/
    /* Event handlers
    /***************************************************************************************************************************/

    onElementClick(e) {
      e.stopPropagation();
      this.instance.onActionClick(this.svgElementInfo, this.elementId, this.elementId, this.rule);
    }

    onEntityClick(e) {
      e.stopPropagation();
      this.instance.onActionClick(this.svgElementInfo, this.entityId, this.elementId, this.rule);
    }

    onActionClick(svgElementInfo, entityId, elementId, rule) {
      if (!rule || !rule.action) {
        if (entityId && (rule.more_info !== false)) {
          this.openMoreInfo(entityId);
        }
        return;
      }

      let calledServiceCount = 0;

      let svgElement = svgElementInfo.svgElement;

      let actions = Array.isArray(rule.action) ? rule.action : [rule.action];
      for (let action of actions) {
        if (action.service || action.service_template) {
          let actionService = this.getActionService(action, entityId, svgElement);

          switch (this.getDomain(actionService)) {
            case 'floorplan':
              this.callFloorplanService(action, entityId, svgElementInfo);
              break;

            default:
              this.callHomeAssistantService(action, entityId, svgElementInfo);
              break;
          }

          calledServiceCount++;
        }
      }

      if (!calledServiceCount) {
        if (entityId && (rule.more_info !== false)) {
          this.openMoreInfo(entityId);
        }
      }
    }

    callFloorplanService(action, entityId, svgElementInfo) {
      let svgElement = svgElementInfo ? svgElementInfo.svgElement : undefined;

      let actionService = this.getActionService(action, entityId, svgElement);
      let actionData = this.getActionData(action, entityId, svgElement);

      switch (this.getService(actionService)) {
        case 'class_toggle':
          if (actionData) {
            let classes = actionData.classes;

            for (let otherElementId of actionData.elements) {
              let otherSvgElement = $(svgElementInfo.svg).find(`[id="${otherElementId}"]`);

              if ($(otherSvgElement).hasClass(classes[0])) {
                $(otherSvgElement).removeClass(classes[0]);
                $(otherSvgElement).addClass(classes[1]);
              }
              else if ($(otherSvgElement).hasClass(classes[1])) {
                $(otherSvgElement).removeClass(classes[1]);
                $(otherSvgElement).addClass(classes[0]);
              }
              else {
                $(otherSvgElement).addClass(actionData.default_class);
              }
            }
          }
          break;

        case 'page_navigate':
          let page_id = actionData.page_id;
          let targetPageInfo = page_id && this.pageInfos[page_id];

          if (targetPageInfo) {
            Object.keys(this.pageInfos).map(key => {
              let pageInfo = this.pageInfos[key];

              if (!pageInfo.isMaster) {
                if ($(pageInfo.svg).css('display') !== 'none') {
                  $(pageInfo.svg).css('display', 'none');
                }
              }
            });

            $(targetPageInfo.svg).css('display', 'initial');
          }
          break;

        case 'variable_set':
          if (actionData.variable) {
            let attributes = [];

            if (actionData.attributes) {
              for (let attribute of actionData.attributes) {
                let attributeValue = this.getActionValue(attribute, entityId, svgElement);
                attributes.push({ name: attribute.attribute, value: attributeValue });
              }
            }

            let value = this.getActionValue(actionData, entityId, svgElement);
            this.setVariable(actionData.variable, value, attributes);
          }
          break;

        default:
          // Unknown floorplan service
          break;
      }
    }

    getActionValue(action, entityId, svgElement) {
      let value = action.value;
      if (action.value_template) {
        value = this.evaluate(action.value_template, entityId, svgElement);
      }
      return value;
    }

    setVariable(variableName, value, attributes, isInitialLoad) {
      this.variables[variableName] = value;

      if (this.hass.states[variableName]) {
        this.hass.states[variableName].state = value;

        for (let attribute of attributes) {
          this.hass.states[variableName].attributes[attribute.name] = attribute.value;
        }
      }

      for (let otherVariableName of Object.keys(this.variables)) {
        this.hass.states[otherVariableName].last_changed = new Date(); // mark all variables as changed
      }

      // Simulate an event change to all entities
      if (!isInitialLoad) {
        this.handleEntitiesDebounced(); // use debounced wrapper
      }
    }

    /***************************************************************************************************************************/
    /* Home Assisant helper functions
    /***************************************************************************************************************************/

    callHomeAssistantService(action, entityId, svgElementInfo) {
      let svgElement = svgElementInfo ? svgElementInfo.svgElement : undefined;

      let actionService = this.getActionService(action, entityId, svgElement);
      let actionData = this.getActionData(action, entityId, svgElement);

      /*
      if (!actionData.entity_id && entityId) {
        actionData.entity_id = entityId;
      }
      */

      this.hass.callService(this.getDomain(actionService), this.getService(actionService), actionData);
    }

    getActionData(action, entityId, svgElement) {
      let data = action.data ? action.data : {};
      if (action.data_template) {
        let result = this.evaluate(action.data_template, entityId, svgElement);
        data = (typeof result === 'string') ? JSON.parse(result) : result;
      }
      return data;
    }

    getActionService(action, entityId, svgElement) {
      let service = action.service;
      if (action.service_template) {
        service = this.evaluate(action.service_template, entityId, svgElement);
      }
      return service;
    }

    getDomain(actionService) {
      return actionService.split(".")[0];
    }

    getService(actionService) {
      return actionService.split(".")[1];
    }

    /***************************************************************************************************************************/
    /* Logging / error handling functions
    /***************************************************************************************************************************/

    handleWindowError(msg, url, lineNo, columnNo, error) {
      this.setIsLoading(false);

      if (msg.toLowerCase().indexOf("script error") >= 0) {
        this.logError('Script error: See browser console for detail');
      }
      else {
        let message = [
          msg,
          'URL: ' + url,
          'Line: ' + lineNo + ', column: ' + columnNo,
          'Error: ' + JSON.stringify(error)
        ].join('<br>');

        this.logError(message);
      }

      return false;
    }

    handleError(error) {
      let message = error;
      if (error.stack) {
        message = `${error.stack}`;
      }
      else if (error.message) {
        message = `${error.message}`;
      }

      this.log('error', message);
    }

    logError(message) {
      this.log('error', message);
    }

    logWarning(area, message) {
      this.log('warning', `${area} ${message}`);
    }

    logInfo(area, message) {
      this.log('info', `${area} ${message}`);
    }

    logDebug(area, message) {
      this.log('debug', `${area} ${message}`);
    }

    log(level, message) {
      let text = `${moment().format("DD-MM-YYYY HH:mm:ss")} ${level.toUpperCase()} ${message}`;

      switch (level) {
        case 'error':
          console.error(text);
          break;

        case 'warning':
          console.warn(text);
          break;

        case 'error':
          console.info(text);
          break;

        default:
          console.log(text);
          break;
      }

      if (!this.config) {
        // Always log messages before the config has been loaded
      }
      else if (!this.logLevels || !this.logLevels.length || (this.logLevels.indexOf(level) < 0)) {
        return;
      }

      let log = $(this.doc).find('#log');
      $(log).find('ul').prepend(`<li class="${level}">${text}</li>`)
      $(log).css('display', 'block');
    }

    /***************************************************************************************************************************/
    /* CSS helper functions
    /***************************************************************************************************************************/

    getStroke(stateConfig) {
      let stroke = undefined;

      for (let cssRule of this.cssRules) {
        if (cssRule.selectorText && cssRule.selectorText.indexOf(`.${stateConfig.class}`) >= 0) {
          if (cssRule.style && cssRule.style.stroke) {
            if (cssRule.style.stroke[0] === '#') {
              stroke = cssRule.style.stroke;
            }
            else {
              let rgb = cssRule.style.stroke.substring(4).slice(0, -1).split(',').map(x => parseInt(x));
              stroke = `#${rgb[0].toString(16)[0]}${rgb[1].toString(16)[0]}${rgb[2].toString(16)[0]}`;
            }
          }
        }
      }

      return stroke;
    }

    getFill(stateConfig) {
      let fill = undefined;

      for (let cssRule of this.cssRules) {
        if (cssRule.selectorText && cssRule.selectorText.indexOf(`.${stateConfig.class}`) >= 0) {
          if (cssRule.style && cssRule.style.fill) {
            if (cssRule.style.fill[0] === '#') {
              fill = cssRule.style.fill;
            }
            else {
              let rgb = cssRule.style.fill.substring(4).slice(0, -1).split(',').map(x => parseInt(x));
              fill = `#${rgb[0].toString(16)}${rgb[1].toString(16)}${rgb[2].toString(16)}`;
            }
          }
        }
      }

      return fill;
    }

    getTransitionColor(fromColor, toColor, value) {
      return (value <= 0) ? fromColor :
        ((value >= 1) ? toColor : this.rgbToHex(this.mix(this.hexToRgb(toColor), this.hexToRgb(fromColor), value)));
    }

    /***************************************************************************************************************************/
    /* General helper functions
    /***************************************************************************************************************************/

    fetchTextResource(resourceUrl, useCache) {
      resourceUrl = this.cacheBuster(resourceUrl);
      useCache = false;

      return new Promise((resolve, reject) => {
        let request = new Request(resourceUrl, {
          cache: (useCache === true) ? 'reload' : 'no-cache',
        });

        fetch(request)
          .then((response) => {
            if (response.ok) {
              return response.text();
            }
            else {
              throw new Error(`Error fetching resource`);
            }
          })
          .then((result) => resolve(result))
          .catch((err) => {
            reject(new URIError(`${resourceUrl}: ${err.message}`));
          });
      });
    }

    fetchImageResource(resourceUrl, useCache) {
      resourceUrl = this.cacheBuster(resourceUrl);
      useCache = false;

      return new Promise((resolve, reject) => {
        let request = new Request(resourceUrl, {
          cache: (useCache === true) ? 'reload' : 'no-cache',
          headers: new Headers({ 'Content-Type': 'text/plain; charset=x-user-defined' }),
        });

        fetch(request)
          .then((response) => {
            if (response.ok) {
              return response.arrayBuffer();
            }
            else {
              throw new Error(`Error fetching resource`);
            }
          })
          .then((result) => resolve(`data:image/jpeg;base64,${this.arrayBufferToBase64(result)}`))
          .catch((err) => {
            reject(new URIError(`${resourceUrl}: ${err.message}`));
          });
      });
    }

    /***************************************************************************************************************************/
    /* Utility functions
    /***************************************************************************************************************************/

    getArray(list) {
      return Array.isArray(list) ? list : Object.keys(list).map(key => list[key]);
    }

    arrayBufferToBase64(buffer) {
      let binary = '';
      let bytes = [].slice.call(new Uint8Array(buffer));

      bytes.forEach((b) => binary += String.fromCharCode(b));

      let base64 = window.btoa(binary);

      // IOS / Safari will not render base64 images unless length is divisible by 4
      while ((base64.length % 4) > 0) {
        base64 += '=';
      }

      return base64;
    }

    base64Encodebase64Encode(str) {
      let CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
      let out = "", i = 0, len = str.length, c1, c2, c3;
      while (i < len) {
        c1 = str.charCodeAt(i++) & 0xff;
        if (i === len) {
          out += CHARS.charAt(c1 >> 2);
          out += CHARS.charAt((c1 & 0x3) << 4);
          out += "==";
          break;
        }
        c2 = str.charCodeAt(i++);
        if (i === len) {
          out += CHARS.charAt(c1 >> 2);
          out += CHARS.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4));
          out += CHARS.charAt((c2 & 0xF) << 2);
          out += "=";
          break;
        }
        c3 = str.charCodeAt(i++);
        out += CHARS.charAt(c1 >> 2);
        out += CHARS.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4));
        out += CHARS.charAt(((c2 & 0xF) << 2) | ((c3 & 0xC0) >> 6));
        out += CHARS.charAt(c3 & 0x3F);
      }

      // IOS / Safari will not render base64 images unless length is divisible by 4
      while ((out.length % 4) > 0) {
        out += '=';
      }

      return out;
    }

    cacheBuster(url) {
      return `${url}${(url.indexOf('?') >= 0) ? '&' : '?'}_=${new Date().getTime()}`;
    }

    debounce(func, wait, immediate) {
      let timeout;
      return function () {
        let context = this, args = arguments;

        let later = function () {
          timeout = null;
          if (!immediate) func.apply(context, args);
        };

        let callNow = immediate && !timeout;
        clearTimeout(timeout);
        timeout = setTimeout(later, wait);

        if (callNow) func.apply(context, args);
      };
    }

    equal(a, b) {
      if (a === b) return true;

      let arrA = Array.isArray(a)
        , arrB = Array.isArray(b)
        , i;

      if (arrA && arrB) {
        if (a.length != b.length) return false;
        for (i = 0; i < a.length; i++)
          if (!this.equal(a[i], b[i])) return false;
        return true;
      }

      if (arrA != arrB) return false;

      if (a && b && typeof a === 'object' && typeof b === 'object') {
        let keys = Object.keys(a);
        if (keys.length !== Object.keys(b).length) return false;

        let dateA = a instanceof Date
          , dateB = b instanceof Date;
        if (dateA && dateB) return a.getTime() == b.getTime();
        if (dateA != dateB) return false;

        let regexpA = a instanceof RegExp
          , regexpB = b instanceof RegExp;
        if (regexpA && regexpB) return a.toString() == b.toString();
        if (regexpA != regexpB) return false;

        for (i = 0; i < keys.length; i++)
          if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false;

        for (i = 0; i < keys.length; i++)
          if (!this.equal(a[keys[i]], b[keys[i]])) return false;

        return true;
      }

      return false;
    }

    /***************************************************************************************************************************/
    /* Color functions
    /***************************************************************************************************************************/

    rgbToHex(rgb) {
      return "#" + ((1 << 24) + (rgb[0] << 16) + (rgb[1] << 8) + rgb[2]).toString(16).slice(1);
    }

    hexToRgb(hex) {
      // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
      let shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
      hex = hex.replace(shorthandRegex, (m, r, g, b) => {
        return r + r + g + g + b + b;
      });

      let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
      return result ? {
        r: parseInt(result[1], 16),
        g: parseInt(result[2], 16),
        b: parseInt(result[3], 16)
      } : null;
    }

    mix(color1, color2, weight) {
      let p = weight;
      let w = p * 2 - 1;
      let w1 = ((w / 1) + 1) / 2;
      let w2 = 1 - w1;
      let rgb = [
        Math.round(color1.r * w1 + color2.r * w2),
        Math.round(color1.g * w1 + color2.g * w2),
        Math.round(color1.b * w1 + color2.b * w2)
      ];
      return rgb;
    }

    wrap(svgTextElement, width, content) {
      let $text = $(svgTextElement);

      let words = content.split(/\s+/).reverse();
      let line = [];
      let lineNumber = 0;
      let lineHeight = 1.1; // ems
      let x = $text.attr("x");
      let y = $text.attr("y");
      //let dy = 0; //parseFloat($text.attr("dy")),

      let $tspan = $text.append("tspan")
        .attr("x", x)
        .attr("y", y)
        .attr("dy", dy + "em")
        .text(null);

      let word;
      while (word = words.pop()) {
        line.push(word);
        $tspan.text(line.join(" "));
        if ($tspan.node().getComputedTextLength() > width) {
          line.pop();
          $tspan.text(line.join(" "));
          line = [word];
          $tspan = $text.append("tspan")
            .attr("x", x)
            .attr("y", y)
            .attr("dy", ++lineNumber * lineHeight + dy + "em")
            .text(word);
        }
      }
    }
  }

  window.Floorplan = Floorplan;
}).call(this);