/**
 * @file
 * Defines the behavior of the Drupal administration navbar.
 */

(function ($, Backbone, Drupal) {

"use strict";

/**
 * Registers tabs with the navbar.
 *
 * The navbar allows modules to register top-level tabs. These may point
 * directly to a resource or toggle the visibility of a tray.
 *
 * Modules register tabs with hook_navbar().
 */
Drupal.behaviors.navbar = {

  attach: function (context) {
    // Verify that the user agent understands media queries. Complex admin
    // navbar layouts require media query support.
    if (!window.matchMedia('only screen').matches) {
      return;
    }
    // Process the administrative navbar.
    $(context).find('#navbar-administration').once('navbar', function () {

      // Add a class to the body indicating that the navbar is present on the
      // page.
      // @see https://drupal.org/node/1940104
      $('body').addClass('navbar-administration');

      // Merge run-time settings with the defaults.
      var options = $.extend(
        {
          breakpoints: {
            'narrow': '',
            'standard': '',
            'wide': ''
          }
        },
        Drupal.settings.navbar,
        // Merge strings on top of drupalSettings so that they are not mutable.
        {
          strings: {
            horizontal: Drupal.t('Horizontal orientation'),
            vertical: Drupal.t('Vertical orientation')
          }
        }
      );

      // Establish the navbar models and views.
      var model = Drupal.navbar.models.navbarModel = new Drupal.navbar.NavbarModel({
        locked: JSON.parse(localStorage.getItem('Drupal.navbar.trayVerticalLocked')) || false,
        activeTab: JSON.parse(localStorage.getItem('Drupal.navbar.activeTab'))
      });
      Drupal.navbar.views.navbarVisualView = new Drupal.navbar.NavbarVisualView({
        el: this,
        model: model,
        strings: options.strings
      });
      Drupal.navbar.views.navbarAuralView = new Drupal.navbar.NavbarAuralView({
        el: this,
        model: model,
        strings: options.strings
      });
      Drupal.navbar.views.NavbarVisualView = new Drupal.navbar.BodyVisualView({
        el: this,
        model: model
      });

      // Render collapsible menus.
      var menuModel = Drupal.navbar.models.menuModel = new Drupal.navbar.MenuModel();
      Drupal.navbar.views.menuVisualView = new Drupal.navbar.MenuVisualView({
        el: $(this).find('.navbar-menu-administration').get(0),
        model: menuModel,
        strings: options.strings
      });

      // Handle the resolution of Drupal.navbar.setSubtrees.
      // This is handled with a deferred so that the function may be invoked
      // asynchronously.
      Drupal.navbar.setSubtrees.done(function (subtrees) {
        menuModel.set('subtrees', subtrees);
        localStorage.setItem('Drupal.navbar.subtrees', JSON.stringify(subtrees));
        // Indicate on the navbarModel that subtrees are now loaded.
        model.set('areSubtreesLoaded', true);
      });
      // Resolve this immediately since we're simply loading all the submenu
      // items right from the server each time until we can resolve the JSONP
      // loading issue in Drupal 7.
      Drupal.navbar.setSubtrees.resolve(null);

      // Attach a listener to the configured media query breakpoints.
      for (var label in options.breakpoints) {
        if (options.breakpoints.hasOwnProperty(label)) {
          var mq = options.breakpoints[label];
          var mql = Drupal.navbar.mql[label] = window.matchMedia(mq);
          // Curry the model and the label of the media query breakpoint to the
          // mediaQueryChangeHandler function.
          mql.addListener(Drupal.navbar.mediaQueryChangeHandler.bind(null, model, label));
          // Fire the mediaQueryChangeHandler for each configured breakpoint
          // so that they process once.
          Drupal.navbar.mediaQueryChangeHandler.call(null, model, label, mql);
        }
      }

      // Trigger an initial attempt to load menu subitems. This first attempt
      // is made after the media query handlers have had an opportunity to
      // process. The navbar starts in the vertical orientation by default,
      // unless the viewport is wide enough to accomodate a horizontal
      // orientation. Thus we give the Navbar a chance to determine if it
      // should be set to horizontal orientation before attempting to load menu
      // subtrees.
      Drupal.navbar.views.navbarVisualView.loadSubtrees();

      $(document)
        // Update the model when the viewport offset changes.
        .on('drupalViewportOffsetChange.navbar', function (event, offsets) {
          model.set('offsets', offsets);
        })
        // The overlay will hide viewport overflow, potentially stranding tray
        // items that are offscreen. The navbar will adjust tray presentation
        // to prevent this when viewport overflow is hidden.
        .on('drupalOverlayOpen.navbar', function () {
          model.set('isViewportOverflowConstrained', true);
        })
        .on('drupalOverlayClose.navbar', function () {
          model.set('isViewportOverflowConstrained', false);
        });

      // Broadcast model changes to other modules.
      model
        .on('change:orientation', function (model, orientation) {
          $(document).trigger('drupalNavbarOrientationChange', orientation);
        })
        .on('change:activeTab', function (model, tab) {
          $(document).trigger('drupalNavbarTabChange', tab);
        })
        .on('change:activeTray', function (model, tray) {
          $(document).trigger('drupalNavbarTrayChange', tray);
        });
    });

    // Invoke the Navbar menu script for core modules.
    $('.navbar-menu-user').drupalNavbarMenu();
    $('.navbar-menu-shortcuts .navbar-lining > .navbar-menu').drupalNavbarMenu();
  }
};

/**
 * Navbar Backbone objects.
 */
Drupal.navbar = {

  // A hash of View instances.
  views: {},

  // A hash of Model instances.
  models: {},

  // A hash of MediaQueryList objects tracked by the navbar.
  mql: {},

  /**
   * Accepts a list of subtree menu elements.
   *
   * A deferred object that is resolved by an inlined JavaScript callback.
   *
   * JSONP callback.
   * @see navbar_subtrees_jsonp().
   *
   * Let's build our own $.Deferred()!
   */
  setSubtrees: (function () {
    return {
      resolve: function (subtrees) {
        this.callback.call(null, subtrees);
      },
      done: function (callback) {
        this.callback = callback;
      }
    };
  }()),

  /**
   * Respond to configured narrow media query changes.
   */
  mediaQueryChangeHandler: function (model, label, mql) {
    switch (label) {
      case 'narrow':
        model.set({
          'isOriented': mql.matches,
          'isTrayToggleVisible': false
        });
        // If the navbar doesn't have an explicit orientation yet, or if the
        // narrow media query doesn't match then set the orientation to
        // vertical.
        if (!mql.matches || !model.get('orientation')) {
          model.set({'orientation': 'vertical'}, {validate: true});
        }
        break;
      case 'standard':
        model.set({
          'isFixed': mql.matches
        });
        break;
      case 'wide':
        model.set({
          'orientation': ((mql.matches) ? 'horizontal' : 'vertical')
        }, {validate: true});
        // The tray orientation toggle visibility does not need to be validated.
        model.set({
          'isTrayToggleVisible': mql.matches
        });
        break;
      default:
        break;
    }
  },

  /**
   * Backbone model for the navbar.
   */
  NavbarModel: Backbone.Model.extend({
    defaults: {
      // The active navbar tab. All other tabs should be inactive under
      // normal circumstances. It will remain active across page loads. The
      // active item is stored as an ID selector e.g. '#navbar-item--1'.
      activeTab: null,
      // Represents whether a tray is open or not. Stored as an ID selector e.g.
      // '#navbar-item--1-tray'.
      activeTray: null,
      // Indicates whether the navbar is displayed in an oriented fashion,
      // either horizontal or vertical.
      isOriented: false,
      // Indicates whether the navbar is positioned absolute (false) or fixed
      // (true).
      isFixed: false,
      // Menu subtrees are loaded through an AJAX request only when the Navbar
      // is set to a vertical orientation.
      areSubtreesLoaded: false,
      // If the viewport overflow becomes constrained, such as when the overlay
      // is open, isFixed must be true so that elements in the trays aren't
      // lost offscreen and impossible to get to.
      isViewportOverflowConstrained: false,
      // The orientation of the active tray.
      orientation: 'vertical',
      // A tray is locked if a user toggled it to vertical. Otherwise a tray
      // will switch between vertical and horizontal orientation based on the
      // configured breakpoints. The locked state will be maintained across page
      // loads.
      locked: false,
      // Indicates whether the tray orientation toggle is visible.
      isTrayToggleVisible: false,
      // The height of the navbar.
      height: null,
      // The current viewport offsets determined by Drupal.displace(). The
      // offsets suggest how a module might position is components relative to
      // the viewport.
      offsets: {
        top: 0,
        right: 0,
        bottom: 0,
        left: 0
      }
    },

    /**
     * {@inheritdoc}
     */
    validate: function (attributes, options) {
      // Prevent the orientation being set to horizontal if it is locked, unless
      // override has not been passed as an option.
      if (attributes.orientation === 'horizontal' && this.get('locked') && !options.override) {
        return Drupal.t('The navbar cannot be set to a horizontal orientation when it is locked.');
      }
    }
  }),

  /**
   * Backbone view for the aural feedback of the navbar.
   */
  NavbarAuralView: Backbone.View.extend({

    /**
     * {@inheritdoc}
     */
    initialize: function (options) {
      this.strings = options.strings;

      this.model.on('change:orientation', this.onOrientationChange, this);
      this.model.on('change:activeTray', this.onActiveTrayChange, this);
    },

    /**
     * Announces an orientation change.
     *
     * @param Drupal.Navbar.NavbarModel model
     * @param String orientation
     *   The new value of the orientation attribute in the model.
     */
    onOrientationChange: function (model, orientation) {
      Drupal.announce(Drupal.t('Tray orientation changed to @orientation.', {
        '@orientation': orientation
      }));
    },

    /**
     * Announces a changed active tray.
     *
     * @param Drupal.Navbar.NavbarModel model
     * @param Element orientation
     *   The new value of the tray attribute in the model.
     */
    onActiveTrayChange: function (model, tray) {
      var relevantTray = (tray === null) ? model.previous('activeTray') : tray;
      var trayName = relevantTray.querySelector('.navbar-tray-name').textContent;
      var text;
      if (tray === null) {
        text = Drupal.t('Tray "@tray" closed.', { '@tray': trayName });
      }
      else {
        text = Drupal.t('Tray "@tray" opened.', { '@tray': trayName });
      }
      Drupal.announce(text);
    }
  }),

  /**
   * Backbone view for the navbar element. Listens to mouse & touch.
   */
  NavbarVisualView: Backbone.View.extend({

    events: function () {
      // Prevents delay and simulated mouse events.
      var touchEndToClick = function (event) {
        event.preventDefault();
        event.target.click();
      };

      return {
        'click .navbar-bar [data-navbar-tab-trigger]': 'onTabClick',
        'click .navbar-toggle-orientation button': 'onOrientationToggleClick',
        'touchend .navbar-bar [data-navbar-tab-trigger]': touchEndToClick,
        'touchend .navbar-toggle-orientation button': touchEndToClick
      };
    },

    /**
     * {@inheritdoc}
     */
    initialize: function (options) {
      this.strings = options.strings;

      this.model.on('change:activeTab change:orientation change:isOriented change:isTrayToggleVisible', this.render, this);
      this.model.on('change:mqMatches', this.onMediaQueryChange, this);
      this.model.on('change:offsets', this.adjustPlacement, this);

      // Add the tray orientation toggles.
      this.$el
        .find('.navbar-tray .navbar-lining')
        .append(Drupal.theme('navbarOrientationToggle'));

      // Trigger an activeTab change so that listening scripts can respond on
      // page load. This will call render.
      this.model.trigger('change:activeTab');
    },

    /**
     * {@inheritdoc}
     */
    render: function () {
      this.updateTabs();
      this.updateTrayOrientation();
      this.updateBarAttributes();
      // Load the subtrees if the orientation of the navbar is changed to
      // vertical. This condition responds to the case that the navbar switches
      // from horizontal to vertical orientation. The navbar starts in a
      // vertical orientation by default and then switches to horizontal during
      // initialization if the media query conditions are met. Simply checking
      // that the orientation is vertical here would result in the subtrees
      // always being loaded, even when the navbar initialization ultimately
      // results in a horizontal orientation.
      //
      // @see Drupal.behaviors.navbar.attach() where admin menu subtrees
      // loading is invoked during initialization after media query conditions
      // have been processed.
      if (this.model.changed.orientation === 'vertical' || this.model.changed.activeTab) {
        this.loadSubtrees();
      }
      // Trigger a recalculation of viewport displacing elements. Use setTimeout
      // to ensure this recalculation happens after changes to visual elements
      // have processed.
      window.setTimeout(function () {
        Drupal.displace(true);
      }, 0);
      return this;
    },

    /**
     * Responds to a navbar tab click.
     *
     * @param jQuery.Event event
     */
    onTabClick: function (event) {
      // If this tab has a tray associated with it, it is considered an
      // activatable tab.
      if (event.target.hasAttribute('data-navbar-tray')) {
        var activeTab = this.model.get('activeTab');
        var id = event.target.id;
        // Set the event target as the active item if it is not already.
        this.model.set('activeTab', (!activeTab || id !== activeTab) ? id : null);

        event.preventDefault();
        event.stopPropagation();
      }
    },

    /**
     * Toggles the orientation of a navbar tray.
     *
     * @param jQuery.Event event
     */
    onOrientationToggleClick: function (event) {
      if ($(event.target).hasClass('navbar-toggle')) {
        var orientation = this.model.get('orientation');
        // Determine the toggle-to orientation.
        var antiOrientation = (orientation === 'vertical') ? 'horizontal' : 'vertical';
        var locked = (antiOrientation === 'vertical') ? true : false;
        // Remember the locked state.
        if (locked) {
          localStorage.setItem('Drupal.navbar.trayVerticalLocked', 'true');
        }
        else {
          localStorage.removeItem('Drupal.navbar.trayVerticalLocked');
        }
        // Update the model.
        this.model.set({
          locked: locked,
          orientation: antiOrientation
        }, {
          validate: true,
          override: true
        });

        event.preventDefault();
        event.stopPropagation();
      }
    },

    /**
     * Updates the display of the tabs: toggles a tab and the associated tray.
     */
    updateTabs: function () {
      var $tab = $('#' + this.model.get('activeTab'));
      // Deactivate the previous tab.
      $('#' + this.model.previous('activeTab'))
        .removeClass('navbar-active')
        .attr('aria-pressed', false);
      // Deactivate the previous tray.
      $(this.model.previous('activeTray'))
        .removeClass('navbar-active');

      // Activate the selected tab.
      if ($tab.length > 0) {
        $tab
          .addClass('navbar-active')
          // Mark the tab as pressed.
          .attr('aria-pressed', true);
        var name = $tab.attr('data-navbar-tray');
        // Store the active tab name or remove the setting.
        var id = $tab.get(0).id;
        if (id) {
          localStorage.setItem('Drupal.navbar.activeTab', JSON.stringify(id));
        }
        // Activate the associated tray.
        var $tray = this.$el.find('[data-navbar-tray="' + name + '"].navbar-tray');
        if ($tray.length) {
          $tray.addClass('navbar-active');
          this.model.set('activeTray', $tray.get(0));
        }
        else {
          // There is no active tray.
          this.model.set('activeTray', null);
        }
      }
      else {
        // There is no active tray.
        this.model.set('activeTray', null);
        localStorage.removeItem('Drupal.navbar.activeTab');
      }
    },

    /**
     * Update the attributes of the navbar bar element.
     */
    updateBarAttributes: function () {
      var isOriented = this.model.get('isOriented');
      if (isOriented) {
        this.$el.find('.navbar-bar').attr('data-offset-top', '');
      }
      else {
        this.$el.find('.navbar-bar').removeAttr('data-offset-top');
      }
      // Toggle between a basic vertical view and a more sophisticated
      // horizontal and vertical display of the navbar bar and trays.
      this.$el.toggleClass('navbar-oriented', isOriented);
    },

    /**
     * Updates the orientation of the active tray if necessary.
     */
    updateTrayOrientation: function () {
      var orientation = this.model.get('orientation');
      // The antiOrientation is used to render the view of action buttons like
      // the tray orientation toggle.
      var antiOrientation = (orientation === 'vertical') ? 'horizontal' : 'vertical';
      // Update the orientation of the trays.
      var $trays = this.$el.find('.navbar-tray')
        .removeClass('navbar-tray-horizontal navbar-tray-vertical')
        .addClass('navbar-tray-' + orientation);

      // Update the tray orientation toggle button.
      var iconClass = 'navbar-icon-toggle-' + orientation;
      var iconAntiClass = 'navbar-icon-toggle-' + antiOrientation;
      var $orientationToggle = this.$el.find('.navbar-toggle-orientation')
        .toggle(this.model.get('isTrayToggleVisible'));
      $orientationToggle.find('button')
        .val(antiOrientation)
        .text(this.strings[antiOrientation])
        .removeClass(iconClass)
        .addClass(iconAntiClass);

      // Update data offset attributes for the trays.
      var dir = document.documentElement.dir;
      var edge = (dir === 'rtl') ? 'right' : 'left';
      // Remove data-offset attributes from the trays so they can be refreshed.
      $trays
        .removeAttr('data-offset-left')
        .removeAttr('data-offset-right')
        .removeAttr('data-offset-top');
      // If an active vertical tray exists, mark it as an offset element.
      $trays.filter('.navbar-tray-vertical.navbar-active').attr('data-offset-' + edge, '');
      // If an active horizontal tray exists, mark it as an offset element.
      $trays.filter('.navbar-tray-horizontal.navbar-active').attr('data-offset-top', '');
    },

    /**
     * Sets the tops of the trays so that they align with the bottom of the bar.
     */
    adjustPlacement: function () {
      var $trays = this.$el.find('.navbar-tray');
      if (!this.model.get('isOriented')) {
        $trays.css('padding-top', 0);
        $trays.removeClass('navbar-tray-horizontal').addClass('navbar-tray-vertical');
      }
      else {
        // The navbar container is invisible. Its placement is used to determine
        // the container for the trays.
        $trays.css('padding-top', this.$el.find('.navbar-bar').outerHeight(true));
      }
    },

    /**
     * Calls the endpoint URI that will return rendered subtrees with JSONP.
     *
     * The rendered admin menu subtrees HTML is cached on the client in
     * localStorage until the cache of the admin menu subtrees on the server-
     * side is invalidated. The subtreesHash is stored in localStorage as well
     * and compared to the subtreesHash in drupalSettings to determine when the
     * admin menu subtrees cache has been invalidated.
     */
    loadSubtrees: function () {
      var $activeTab = $('#' + this.model.get('activeTab'));
      var orientation = this.model.get('orientation');
      // Only load and render the admin menu subtrees if:
      //   (1) They have not been loaded yet.
      //   (2) The active tab is the administration menu tab, indicated by the
      //       presence of the data-drupal-subtrees attribute.
      //   (3) The orientation of the tray is vertical.
      if (!this.model.get('areSubtreesLoaded') && $activeTab.data('drupal-subtrees') !== undefined && orientation === 'vertical') {
        var subtreesHash = drupalSettings.navbar.subtreesHash;
        var endpoint = Drupal.url('navbar/subtrees/' + subtreesHash);
        var cachedSubtreesHash = localStorage.getItem('Drupal.navbar.subtreesHash');
        var cachedSubtrees = JSON.parse(localStorage.getItem('Drupal.navbar.subtrees'));
        var isVertical = this.model.get('orientation') === 'vertical';
        // If we have the subtrees in localStorage and the subtree hash has not
        // changed, then use the cached data.
        if (isVertical && subtreesHash === cachedSubtreesHash && cachedSubtrees) {
          Drupal.navbar.setSubtrees.resolve(cachedSubtrees);
        }
        // Only make the call to get the subtrees if the orientation of the
        // navbar is vertical.
        else if (isVertical) {
          // Remove the cached menu information.
          localStorage.removeItem('Drupal.navbar.subtreesHash');
          localStorage.removeItem('Drupal.navbar.subtrees');
          // The response from the server will call the resolve method of the
          // Drupal.navbar.setSubtrees Promise.
          $.ajax(endpoint);
          // Cache the hash for the subtrees locally.
          localStorage.setItem('Drupal.navbar.subtreesHash', subtreesHash);
        }
      }
    }
  }),

  /**
   * Backbone Model for collapsible menus.
   */
  MenuModel: Backbone.Model.extend({
    defaults: {
      subtrees: {}
    }
  }),

  /**
   * Backbone View for collapsible menus.
   */
  MenuVisualView: Backbone.View.extend({
    /**
     * {@inheritdoc}
     */
    initialize: function () {
      this.model.on('change:subtrees', this.render, this);
    },

    /**
     * {@inheritdoc}
     */
    render: function () {
      var subtrees = this.model.get('subtrees');
      // Add subtrees.
      for (var id in subtrees) {
        if (subtrees.hasOwnProperty(id)) {
          this.$el
            .find('#navbar-link-' + id)
            .once('navbar-subtrees')
            .after(subtrees[id]);
        }
      }
      // Render the main menu as a nested, collapsible accordion.
      if ('drupalNavbarMenu' in $.fn) {
        this.$el
          .children('.navbar-menu')
          .drupalNavbarMenu();
      }
    }
  }),

  /**
   * Adjusts the body element with the navbar position and dimension changes.
   */
  BodyVisualView: Backbone.View.extend({

    /**
     * {@inheritdoc}
     */
    initialize: function () {
      this.model.on('change:orientation change:offsets change:activeTray change:isOriented change:isFixed change:isViewportOverflowConstrained', this.render, this);
    },

    /**
     * {@inheritdoc}
     */
    render: function () {
      var $body = $('body');
      var orientation = this.model.get('orientation');
      var isOriented = this.model.get('isOriented');
      var isViewportOverflowConstrained = this.model.get('isViewportOverflowConstrained');

      $body
        // We are using JavaScript to control media-query handling for two
        // reasons: (1) Using JavaScript let's us leverage the breakpoint
        // configurations and (2) the CSS is really complex if we try to hide
        // some styling from browsers that don't understand CSS media queries.
        // If we drive the CSS from classes added through JavaScript,
        // then the CSS becomes simpler and more robust.
        .toggleClass('navbar-vertical', (orientation === 'vertical'))
        .toggleClass('navbar-horizontal', (isOriented && orientation === 'horizontal'))
        // When the navbar is fixed, it will not scroll with page scrolling.
        .toggleClass('navbar-fixed', (isViewportOverflowConstrained || this.model.get('isFixed')))
        // Toggle the navbar-tray-open class on the body element. The class is
        // applied when a navbar tray is active. Padding might be applied to
        // the body element to prevent the tray from overlapping content.
        .toggleClass('navbar-tray-open', !!this.model.get('activeTray'))
        // Apply padding to the top of the body to offset the placement of the
        // navbar bar element.
        .css('padding-top', this.model.get('offsets').top);
    }
  })
};

/**
 * A toggle is an interactive element often bound to a click handler.
 *
 * @return {String}
 *   A string representing a DOM fragment.
 */
Drupal.theme.navbarOrientationToggle = function () {
  return '<div class="navbar-toggle-orientation"><div class="navbar-lining">' +
    '<button class="navbar-icon navbar-toggle" type="button"></button>' +
    '</div></div>';
};

}(jQuery, Backbone, Drupal));
