
/*
 * BackgroundCheck
 * http://kennethcachia.com/background-check
 *
 * v1.2.2
 */

(function (root, factory) {

  if (typeof define === 'function' && define.amd) {
    define(factory);
  } else {
    root.BackgroundCheck = factory(root);
  }

}(this, function () {

  'use strict';

  var resizeEvent = window.orientation !== undefined ? 'orientationchange' : 'resize';
  var supported;
  var canvas;
  var context;
  var throttleDelay;
  var viewport;
  var attrs = {};


  /*
   * Initializer
   */
  function init(a) {

    if (a === undefined || a.targets === undefined) {
      throw 'Missing attributes';
    }

    // Default values
    attrs.debug         = checkAttr(a.debug, false);
    attrs.debugOverlay  = checkAttr(a.debugOverlay, false);
    attrs.targets       = getElements(a.targets);
    attrs.images        = getElements(a.images || 'img', true);
    attrs.changeParent  = checkAttr(a.changeParent, false);
    attrs.threshold     = checkAttr(a.threshold, 50);
    attrs.minComplexity = checkAttr(a.minComplexity, 30);
    attrs.minOverlap    = checkAttr(a.minOverlap, 50);
    attrs.windowEvents  = checkAttr(a.windowEvents, true);
    attrs.maxDuration   = checkAttr(a.maxDuration, 500);

    attrs.mask = checkAttr(a.mask, {
      r: 0,
      g: 255,
      b: 0
    });

    attrs.classes = checkAttr(a.classes, {
      dark: 'background--dark',
      light: 'background--light',
      complex: 'background--complex'
    });

    if (supported === undefined) {
      checkSupport();

      if (supported) {
        canvas.style.position = 'fixed';
        canvas.style.top = '0px';
        canvas.style.left = '0px';
        canvas.style.width = '100%';
        canvas.style.height = '100%';

        window.addEventListener(resizeEvent, throttle.bind(null, function () {
          resizeCanvas();
          check();
        }));

        window.addEventListener('scroll', throttle.bind(null, check));

        resizeCanvas();
        check();
      }
    }
  }


  /*
   * Destructor
   */
  function destroy() {
    supported = null;
    canvas = null;
    context = null;
    attrs = {};

    if (throttleDelay) {
      clearTimeout(throttleDelay);
    }
  }


  /*
   * Output debug logs
   */
  function log(msg) {

    if (get('debug')) {
      console.log(msg);
    }
  }


  /*
   * Get attribute value, use a default
   * when undefined
   */
  function checkAttr(value, def) {
    checkType(value, typeof def);
    return (value === undefined) ? def : value;
  }


  /*
   * Reject unwanted types
   */
  function checkType(value, type) {

    if (value !== undefined && typeof value !== type) {
      throw 'Incorrect attribute type';
    }
  }


  /*
   * Convert elements with background-image
   * to Images
   */
  function checkForCSSImages(els) {
    var el;
    var url;
    var list = [];

    for (var e = 0; e < els.length; e++) {
      el = els[e];
      list.push(el);

      if (el.tagName !== 'IMG') {
        url = window.getComputedStyle(el).backgroundImage;

        // Ignore multiple backgrounds
        if (url.split(/,url|, url/).length > 1) {
          throw 'Multiple backgrounds are not supported';
        }

        if (url && url !== 'none') {
          list[e] = {
            img: new Image(),
            el: list[e]
          };

          url = url.slice(4, -1);
          url = url.replace(/"/g, '');

          list[e].img.src = url;
          log('CSS Image - ' + url);
        } else {
          throw 'Element is not an <img> but does not have a background-image';
        }
      }
    }

    return list;
  }


  /*
   * Check for String, Element or NodeList
   */
  function getElements(selector, convertToImages) {
    var els = selector;

    if (typeof selector === 'string') {
      els = document.querySelectorAll(selector);
    } else if (selector && selector.nodeType === 1) {
      els = [selector];
    }

    if (!els || els.length === 0 || els.length === undefined) {
      throw 'Elements not found';
    } else {

      if (convertToImages) {
        els = checkForCSSImages(els);
      }

      els = Array.prototype.slice.call(els);
    }

    return els;
  }


  /*
   * Check if browser supports <canvas>
   */
  function checkSupport() {
    canvas = document.createElement('canvas');

    if (canvas && canvas.getContext) {
      context = canvas.getContext('2d');
      supported = true;
    } else {
      supported = false;
    }

    showDebugOverlay();
  }


  /*
   * Show <canvas> on top of page
   */
  function showDebugOverlay() {

    if (get('debugOverlay')) {
      canvas.style.opacity = 0.5;
      canvas.style.pointerEvents = 'none';
      document.body.appendChild(canvas);
    } else {

      // Check if it was previously added
      if (canvas.parentNode) {
        canvas.parentNode.removeChild(canvas);
      }
    }
  }


  /*
   * Stop if it's slow
   */
  function kill(start) {
    var duration = new Date().getTime() - start;
    
    log('Duration: ' + duration + 'ms');

    if (duration > get('maxDuration')) {
      // Log a message even when debug is false
      console.log('BackgroundCheck - Killed');
      removeClasses();
      destroy();
    }
  }


  /*
   * Set width and height of <canvas>
   */
  function resizeCanvas() {
    viewport = {
      left: 0,
      top: 0,
      right: document.body.clientWidth,
      bottom: window.innerHeight
    };

    canvas.width = document.body.clientWidth;
    canvas.height = window.innerHeight;
  }


  /*
   * Process px and %, discard anything else
   */
  function getValue(css, parent, delta) {
    var value;
    var percentage;

    if (css.indexOf('px') !== -1) {
      value = parseFloat(css);
    } else if (css.indexOf('%') !== -1) {
      value = parseFloat(css);
      percentage = value / 100;
      value = percentage * parent;

      if (delta) {
        value -= delta * percentage;
      }
    } else {
      value = parent;
    }

    return value;
  }


  /*
   * Calculate top, left, width and height
   * using the object's CSS
   */
  function calculateAreaFromCSS(obj) {
    var css = window.getComputedStyle(obj.el);

    // Force no-repeat and padding-box
    obj.el.style.backgroundRepeat = 'no-repeat';
    obj.el.style.backgroundOrigin = 'padding-box';

    // Background Size
    var size = css.backgroundSize.split(' ');
    var width = size[0];
    var height = size[1] === undefined ? 'auto' : size[1];

    var parentRatio = obj.el.clientWidth / obj.el.clientHeight;
    var imgRatio = obj.img.naturalWidth / obj.img.naturalHeight;

    if (width === 'cover') {

      if (parentRatio >= imgRatio) {
        width = '100%';
        height = 'auto';
      } else {
        width = 'auto';
        size[0] = 'auto';
        height = '100%';
      }

    } else if (width === 'contain') {

      if (1 / parentRatio < 1 / imgRatio) {
        width = 'auto';
        size[0] = 'auto';
        height = '100%';
      } else {
        width = '100%';
        height = 'auto';
      }
    }

    if (width === 'auto') {
      width = obj.img.naturalWidth;
    } else {
      width = getValue(width, obj.el.clientWidth);
    }

    if (height === 'auto') {
      height = (width / obj.img.naturalWidth) * obj.img.naturalHeight;
    } else {
      height = getValue(height, obj.el.clientHeight);
    }

    if (size[0] === 'auto' && size[1] !== 'auto') {
      width = (height / obj.img.naturalHeight) * obj.img.naturalWidth;
    }

    var position = css.backgroundPosition;

    // Fix inconsistencies between browsers
    if (position === 'top') {
      position = '50% 0%';
    } else if (position === 'left') {
      position = '0% 50%';
    } else if (position === 'right') {
      position = '100% 50%';
    } else if (position === 'bottom') {
      position = '50% 100%';
    } else if (position === 'center') {
      position = '50% 50%';
    }

    position = position.split(' ');

    var x;
    var y;

    // Two-value syntax vs Four-value syntax
    if (position.length === 4) {
      x = position[1];
      y = position[3];
    } else {
      x = position[0];
      y = position[1];
    }

    // Use a default value
    y = y || '50%';

    // Background Position
    x = getValue(x, obj.el.clientWidth, width);
    y = getValue(y, obj.el.clientHeight, height);

    // Take care of ex: background-position: right 20px bottom 20px;
    if (position.length === 4) {

      if (position[0] === 'right') {
        x = obj.el.clientWidth - obj.img.naturalWidth - x;
      }

      if (position[2] === 'bottom') {
        y = obj.el.clientHeight - obj.img.naturalHeight - y;
      }
    }

    x += obj.el.getBoundingClientRect().left;
    y += obj.el.getBoundingClientRect().top;

    return {
      left: Math.floor(x),
      right: Math.floor(x + width),
      top: Math.floor(y),
      bottom: Math.floor(y + height),
      width: Math.floor(width),
      height: Math.floor(height)
    };
  }


  /*
   * Get Bounding Client Rect
   */
  function getArea(obj) {
    var area;
    var image;
    var parent;

    if (obj.nodeType) {
      var rect = obj.getBoundingClientRect();

      // Clone ClientRect for modification purposes
      area = {
        left: rect.left,
        right: rect.right,
        top: rect.top,
        bottom: rect.bottom,
        width: rect.width,
        height: rect.height
      };

      parent = obj.parentNode;
      image = obj;
    } else {
      area = calculateAreaFromCSS(obj);
      parent = obj.el;
      image = obj.img;
    }

    parent = parent.getBoundingClientRect();

    area.imageTop = 0;
    area.imageLeft = 0;
    area.imageWidth = image.naturalWidth;
    area.imageHeight = image.naturalHeight;

    var ratio = area.imageHeight / area.height;
    var delta;

    // Stay within the parent's boundary
    if (area.top < parent.top) {
      delta = parent.top - area.top;
      area.imageTop = ratio * delta;
      area.imageHeight -= ratio * delta;
      area.top += delta;
      area.height -= delta;
    }

    if (area.left < parent.left) {
      delta = parent.left - area.left;
      area.imageLeft += ratio * delta;
      area.imageWidth -= ratio * delta;
      area.width -= delta;
      area.left += delta;
    }

    if (area.bottom > parent.bottom) {
      delta = area.bottom - parent.bottom;
      area.imageHeight -= ratio * delta;
      area.height -= delta;
    }

    if (area.right > parent.right) {
      delta = area.right - parent.right;
      area.imageWidth -= ratio * delta;
      area.width -= delta;
    }

    area.imageTop = Math.floor(area.imageTop);
    area.imageLeft = Math.floor(area.imageLeft);
    area.imageHeight = Math.floor(area.imageHeight);
    area.imageWidth = Math.floor(area.imageWidth);

    return area;
  }


  /*
   * Render image on canvas
   */
  function drawImage(image) {
    var area = getArea(image);

    image = image.nodeType ? image : image.img;

    if (area.imageWidth > 0 && area.imageHeight > 0 && area.width > 0 && area.height > 0) {
      context.drawImage(image,
                        area.imageLeft, area.imageTop, area.imageWidth, area.imageHeight,
                        area.left, area.top, area.width, area.height);
    } else {
      log('Skipping image - ' + image.src + ' - area too small');
    }
  }


  /*
   * Add/remove classes
   */
  function classList(node, name, mode) {
    var className = node.className;

    switch (mode) {
    case 'add':
      className += ' ' + name;
      break;
    case 'remove':
      var pattern = new RegExp('(?:^|\\s)' + name + '(?!\\S)', 'g');
      className = className.replace(pattern, '');
      break;
    }

    node.className = className.trim();
  }


  /*
   * Remove classes from element or
   * their parents, depending on checkParent
   */
  function removeClasses(el) {
    var targets = el ? [el] : get('targets');
    var target;

    for (var t = 0; t < targets.length; t++) {
      target = targets[t];
      target = get('changeParent') ? target.parentNode : target;
      
      classList(target, get('classes').light, 'remove');
      classList(target, get('classes').dark, 'remove');
      classList(target, get('classes').complex, 'remove');
    }
  }


  /*
   * Calculate average pixel brightness of a region 
   * and add 'light' or 'dark' accordingly
   */
  function calculatePixelBrightness(target) {
    var dims = target.getBoundingClientRect();
    var brightness;
    var data;
    var pixels = 0;
    var delta;
    var deltaSqr = 0;
    var mean = 0;
    var variance;
    var minOverlap = 0;
    var mask = get('mask');

    if (dims.width > 0 && dims.height > 0) {
      removeClasses(target);

      target = get('changeParent') ? target.parentNode : target;
      data = context.getImageData(dims.left, dims.top, dims.width, dims.height).data;

      for (var p = 0; p < data.length; p += 4) {

        if (data[p] === mask.r && data[p + 1] === mask.g && data[p + 2] === mask.b) {
          minOverlap++;
        } else {
          pixels++;
          brightness = (0.2126 * data[p]) + (0.7152 * data[p + 1]) + (0.0722 * data[p + 2]);
          delta = brightness - mean;
          deltaSqr += delta * delta;
          mean = mean + delta / pixels;
        }
      }

      if (minOverlap <= (data.length / 4) * (1 - (get('minOverlap') / 100))) {
        variance = Math.sqrt(deltaSqr / pixels) / 255;
        mean = mean / 255;
        log('Target: ' + target.className +  ' lum: ' + mean + ' var: ' + variance);
        classList(target, mean <= (get('threshold') / 100) ? get('classes').dark : get('classes').light, 'add');

        if (variance > get('minComplexity') / 100) {
          classList(target, get('classes').complex, 'add');
        }
      }
    }
  }


  /*
   * Test if a is within b's boundary
   */
  function isInside(a, b) {
    a = (a.nodeType ? a : a.el).getBoundingClientRect();
    b = b === viewport ? b : (b.nodeType ? b : b.el).getBoundingClientRect();

    return !(a.right < b.left || a.left > b.right || a.top > b.bottom || a.bottom < b.top);
  }


  /*
   * Process all targets (checkTarget is undefined)
   * or a single target (checkTarget is a previously set target)
   *
   * When not all images are loaded, checkTarget is an image
   * to avoid processing all targets multiple times
   */
  function processTargets(checkTarget) {
    var start = new Date().getTime();
    var mode = (checkTarget && (checkTarget.tagName === 'IMG' || checkTarget.img)) ? 'image' : 'targets';
    var found = checkTarget ? false : true;
    var total = get('targets').length;
    var target;

    for (var t = 0; t < total; t++) {
      target = get('targets')[t];

      if (isInside(target, viewport)) {
        if (mode === 'targets' && (!checkTarget || checkTarget === target)) {
          found = true;
          calculatePixelBrightness(target);
        } else if (mode === 'image' && isInside(target, checkTarget)) {
          calculatePixelBrightness(target);
        }
      }
    }

    if (mode === 'targets' && !found) {
      throw checkTarget + ' is not a target';
    }

    kill(start);
  }


  /*
   * Find the element's zIndex. Also checks
   * the zIndex of its parent
   */
  function getZIndex(el) {
    var calculate = function (el) {
      var zindex = 0;

      if (window.getComputedStyle(el).position !== 'static') {
        zindex = parseInt(window.getComputedStyle(el).zIndex, 10) || 0;

        // Reserve zindex = 0 for elements with position: static;
        if (zindex >= 0) {
          zindex++;
        }
      }

      return zindex;
    };

    var parent = el.parentNode;
    var zIndexParent = parent ? calculate(parent) : 0;
    var zIndexEl = calculate(el);

    return (zIndexParent * 100000) + zIndexEl;
  }


  /*
   * Check zIndexes
   */
  function sortImagesByZIndex(images) {
    var sorted = false;

    images.sort(function (a, b) {
      a = a.nodeType ? a : a.el;
      b = b.nodeType ? b : b.el;

      var pos = a.compareDocumentPosition(b);
      var reverse = 0;

      a = getZIndex(a);
      b = getZIndex(b);

      if (a > b) {
        sorted = true;
      }

      // Reposition if zIndex is the same but the elements are not
      // sorted according to their document position
      if (a === b && pos === 2) {
        reverse = 1;
      } else if (a === b && pos === 4) {
        reverse = -1;
      }

      return reverse || a - b;
    });

    log('Sorted: ' + sorted);

    if (sorted) {
      log(images);
    }

    return sorted;
  }


  /*
   * Main function
   */
  function check(target, avoidClear, imageLoaded) {

    if (supported) {
      var mask = get('mask');

      log('--- BackgroundCheck ---');
      log('onLoad event: ' + (imageLoaded && imageLoaded.src));

      if (avoidClear !== true) {
        context.clearRect(0, 0, canvas.width, canvas.height);
        context.fillStyle = 'rgb(' + mask.r + ', ' + mask.g + ', ' + mask.b + ')';
        context.fillRect(0, 0, canvas.width, canvas.height);
      }

      var processImages = imageLoaded ? [imageLoaded] : get('images');
      var sorted = sortImagesByZIndex(processImages);

      var image;
      var imageNode;
      var loading = false;

      for (var i = 0; i < processImages.length; i++) {
        image = processImages[i];

        if (isInside(image, viewport)) {
          imageNode = image.nodeType ? image : image.img;

          if (imageNode.naturalWidth === 0) {
            loading = true;
            log('Loading... ' + image.src);

            imageNode.removeEventListener('load', check);

            if (sorted) {
              // Sorted -- redraw all images
              imageNode.addEventListener('load', check.bind(null, null, false, null));
            } else {
              // Not sorted -- just draw one image
              imageNode.addEventListener('load', check.bind(null, target, true, image));
            }
          } else {
            log('Drawing: ' + image.src);
            drawImage(image);
          }
        }
      }

      if (!imageLoaded && !loading) {
        processTargets(target);
      } else if (imageLoaded) {
        processTargets(imageLoaded);
      }
    }
  }


  /*
   * Throttle events
   */
  function throttle(callback) {

    if (get('windowEvents') === true) {

      if (throttleDelay) {
        clearTimeout(throttleDelay);
      }

      throttleDelay = setTimeout(callback, 200);
    }
  }


  /*
   * Setter
   */
  function set(property, newValue) {

    if (attrs[property] === undefined) {
      throw 'Unknown property - ' + property;
    } else if (newValue === undefined) {
      throw 'Missing value for ' + property;
    }

    if (property === 'targets' || property === 'images') {

      try {
        newValue = getElements(property === 'images' && !newValue ? 'img' : newValue, property === 'images' ? true : false);
      } catch (err) {
        newValue = [];
        throw err;
      }
    } else {
      checkType(newValue, typeof attrs[property]);
    }

    removeClasses();
    attrs[property] = newValue;
    check();

    if (property === 'debugOverlay') {
      showDebugOverlay();
    }
  }


  /*
   * Getter
   */
  function get(property) {

    if (attrs[property] === undefined) {
      throw 'Unknown property - ' + property;
    }

    return attrs[property];
  }


  /*
   * Get position and size of all images.
   * Used for testing purposes
   */
  function getImageData() {
    var images = get('images');
    var area;
    var data = [];

    for (var i = 0; i < images.length; i++) {
      area = getArea(images[i]);
      data.push(area);
    }

    return data;
  }


  return {
    /*
     * Init and destroy
     */
    init: init,
    destroy: destroy,

    /*
     * Expose main function
     */
    refresh: check,

    /*
     * Setters and getters
     */
    set: set,
    get: get,

    /*
     * Return image data
     */
    getImageData: getImageData
  };

}));
