/*

Freefocus 0.11.2

Copyright (c) 2013-2016 Ilia Ablamonov. Licensed under the MIT license.

*/

(function () {
  'use strict';

  // ==================== Public ===============================================

  var freefocus = window.freefocus = {
    move: move,

    getDimensions: getDimensions,

    populateDimensionsCache: populateDimensionsCache,
    invalidateDimensionsCache: invalidateDimensionsCache,

    setHint: setHint,
    clearHint: clearHint,

    // Will be set later in code
    configuration: undefined
  };

  function getDimensions(element) {
    if (!element) {
      console.error('Can\'t get freefocus dimensions for nothing');
      return { left: 0, top: 0, width: 0, height: 0 };
    }

    var box = getElementBox(element, true, false);
    return {
      left: box.x1,
      top: box.y1,
      width: box.x2 - box.x1,
      height: box.y2 - box.y1
    };
  }

  function populateDimensionsCache(element) {
    if (!element) {
      console.error('Can\'t populate freefocus cache for nothing');
      return;
    }
    getElementBox(element, true, true);
  }

  function invalidateDimensionsCache(element) {
    if (!element) {
      console.error('Can\'t invalidate freefocus cache for nothing');
      return;
    }
    delete element.freefocusDimensions;
  }

  function clearHint(element) {
    if (!element) {
      console.error('Can\'t clear freefocus hint for nothing');
      return;
    }
    for (var move in directions) {
      element.removeAttribute('data-nav-' + move);
    }
  }

  function setHint(element, hint, overwrite) {
    if (!element) {
      console.error('Can\'t set freefocus hints for nothing');
      return;
    }
    for (var move in hint) {
      if (overwrite !== false || !element.getAttribute('data-nav-' + move)) {
        element.setAttribute('data-nav-' + move, hint[move]);
      }
    }
  }

  function move(fromElement, direction, candidatesFn) {
    var target;

    if (!fromElement) {
      console.error('Can\'t move freefocus from nothing');
      return target;
    }

    if (!directions[direction]) {
      console.error('Unknown freefocus direction "' + direction + '"');
      return target;
    }

    if (!candidatesFn) {
      console.error('Can\'t move freefocus without candidates function');
      return target;
    }

    updateFocusPoint(fromElement, direction, configuration.cache);

    var targets = findTargetsFromHint(fromElement, direction, candidatesFn);
    if (targets) {
      if (targets.length <= 1) {
        // If targets is empty, return nothing, it's the result of a `none` hint
        target = targets[0];
      } else {
        // Find the nearest target from ones set by hint
        var targetsFn = function () { return targets; };
        target = findNearestTarget(fromElement, direction, targetsFn);
      }
    } else {
      // If no hint found, find the nearest target from all available candidates
      target = findNearestTarget(fromElement, direction, candidatesFn);
    }

    if (target) {
      moveFocusPoint(target, direction, configuration.cache);
    }

    return target;
  }

  var directions = {
    left: {
      toUnified: function (coords) {
        return { fwd: -coords.x, ort: -coords.y };
      },
      fromUnified: function (coords) {
        return { x: -coords.fwd, y: -coords.ort };
      }
    },
    right: {
      toUnified: function (coords) {
        return { fwd:  coords.x, ort:  coords.y };
      },
      fromUnified: function (coords) {
        return { x:  coords.fwd, y:  coords.ort };
      }
    },
    up: {
      toUnified: function (coords) {
        return { fwd: -coords.y, ort:  coords.x };
      },
      fromUnified: function (coords) {
        return { x:  coords.ort, y: -coords.fwd };
      }
    },
    down: {
      toUnified: function (coords) {
        return { fwd:  coords.y, ort: -coords.x };
      },
      fromUnified: function (coords) {
        return { x: -coords.ort, y:  coords.fwd };
      }
    }
  };

  var hintSources = [
    function (el, direction) {
      return el.getAttribute('data-nav-' + direction);
    }
  ];

  var configuration = freefocus.configuration = {
    maxDistance: Infinity,
    cache: false,

    directions: directions,
    hintSources: hintSources
  };

  // ==================== Private ==============================================

  var focusPoint = {
    freefocusId: -1
  };

  function findTargetsFromHint(element, direction, candidatesFn) {
    var hint = firstMatch(hintSources, function (source) {
      var hint = source(element, direction);
      return hint && hint.trim();
    });

    if (hint) {
      hint = hint.trim();
    }

    if (!hint) {
      return undefined;
    }

    if (hint === 'none') {
      // Empty set => no movement
      return [];
    }

    // Allow to set explicit order by enumerating selectors
    return firstMatch(hint.split(/\s*;\s*/), function (hintSelector) {
      if (!hintSelector) {
        // A `hint` with a trailing `;` creates an empty hintSelector.
        return undefined;
      }
      var candidates = candidatesFn(hintSelector);

      if (!candidates.length) {
        // Avoid firstMatch ending with an empty array
        return undefined;
      }
      return candidates;
    });
  }

  function findNearestTarget(fromElement, direction, candidatesFn) {
    var fromBox = boxInDirection(getElementBox(fromElement, configuration.cache, configuration.cache), direction);

    var candidates = candidatesFn();
    var minDistance = configuration.maxDistance;

    var target;

    for (var i = 0, len = candidates.length; i < len; i++) {
      var candidate = candidates[i];

      // Skip currently focused element
      if (candidate === fromElement) {
        continue;
      }

      var toBox = boxInDirection(getElementBox(candidate, configuration.cache, configuration.cache), direction);

      // Skip elements that are not in the direction of movement
      if (toBox.fwd1 < fromBox.fwd2) {
        continue;
      }

      var dist = distance(fromBox, toBox);

      if (dist < minDistance) {
        target = candidate;
        minDistance = dist;
      }
    }

    return target;
  }

  function distance(fromBox, toBox) {
    var fromPoint = focusPoint.updatedInDirection;

    var toPoint = {
      fwd: toBox.fwd1,
      ort: bound(fromPoint.ort, toBox.ort1, toBox.ort2)
    };

    var fwdDist = Math.abs(toPoint.fwd - fromPoint.fwd);
    var ortDist = Math.abs(toPoint.ort - fromPoint.ort);

    // The Euclidian distance between the current focus point position and
    // its potential position in the candidate.
    // If the two positions have the same coordinate on the axis orthogonal
    // to the navigation direction, dotDist is forced to 0 in order to favor
    // elements in direction of navigation
    var dotDist;
    if (toPoint.ort === fromPoint.ort) {
      dotDist = 0;
    } else {
      dotDist = Math.sqrt(fwdDist * fwdDist + ortDist * ortDist);
    }

    // The overlap between the opposing edges of currently focused element and the candidate.
    // Elements are rewarded for having high overlap with the currently focused element.
    var overlap = boxOverlap(fromBox, toBox);

    return dotDist + fwdDist + 2 * ortDist - Math.sqrt(overlap);
  }

  function boxOverlap(box1, box2) {
    var orts = {
      ort1: box1.ort1,
      ort2: box1.ort2
    };

    if (box2.ort1 > orts.ort1)
      orts.ort1 = box2.ort1;
    if (box2.ort2 < orts.ort2)
      orts.ort2 = box2.ort2;

    var result = orts.ort2 - orts.ort1;
    if (result < 0)
      result = 0;

    return result;
  }

  function computeElementBox(el) {
    var rect = el.getBoundingClientRect();
    return {
      x1: rect.left,
      y1: rect.top,
      x2: rect.right,
      y2: rect.bottom
    };
  }

  function getElementBox(element, readCache, writeCache) {
    var box;

    if (readCache) {
      box = element.freefocusDimensions;
    }
    if (!box) {
      box = computeElementBox(element);
    }
    if (writeCache) {
      element.freefocusDimensions = box;
    }

    return box;
  }

  function boxInDirection(box, direction) {
    var p1 = directions[direction].toUnified({ x: box.x1, y: box.y1 });
    var p2 = directions[direction].toUnified({ x: box.x2, y: box.y2 });
    return {
      fwd1: Math.min(p1.fwd, p2.fwd),
      ort1: Math.min(p1.ort, p2.ort),
      fwd2: Math.max(p1.fwd, p2.fwd),
      ort2: Math.max(p1.ort, p2.ort)
    };
  }

  function boxCoordsToClient(coords, box) {
    return {
      x: coords.x + box.x1,
      y: coords.y + box.y1
    };
  }

  function clientCoordsToBox(coords, box) {
    return {
      x: coords.x - box.x1,
      y: coords.y - box.y1
    };
  }

  function updateFocusPoint(element, direction, cache) {
    var box = getElementBox(element, cache, cache);

    // If the element wasn't focused by freefocus, calculate it
    if (!element.freefocusId || element.freefocusId !== focusPoint.elementId) {
      focusPoint.elementId = assignId(element);
      focusPoint.box = {
        x: (box.x2 - box.x1) / 2,
        y: (box.y2 - box.y1) / 2
      };
    }
    focusPoint.updatedInDirection = directions[direction].toUnified(boxCoordsToClient(focusPoint.box, box));
    focusPoint.updatedInDirection.fwd = boxInDirection(box, direction).fwd2;
  }

  function moveFocusPoint(element, direction, cache) {
    var box = getElementBox(element, cache, cache);

    // Hold reference only for numeric id instead of a full DOM node
    focusPoint.elementId = assignId(element);

    var boxInDir = boxInDirection(box, direction);
    var movedInDirection = {
      fwd: boxInDir.fwd1,
      ort: bound(focusPoint.updatedInDirection.ort, boxInDir.ort1, boxInDir.ort2)
    };
    focusPoint.box = clientCoordsToBox(directions[direction].fromUnified(movedInDirection), box);
  }

  // ==================== Utilities ============================================

  function bound(val, min, max) {
    return Math.min(Math.max(val, min), max);
  }

  function firstMatch(array, fn) {
    for (var i = 0, len = array.length; i < len; i++) {
      var result = fn(array[i]);
      if (result) {
        return result;
      }
    }
  }

  var lastElementId = 0;
  function assignId(element) {
    var elementId = element.freefocusId;
    if (!elementId) {
      elementId = element.freefocusId = ++lastElementId;
    }
    return elementId;
  }
})();