'use strict';

var URL_MATCH = /^(file|ftp|http|https):\/\/(\w+:{0,1}\w*@)?([\w\.-]*)(:([0-9]+))?(\/[^\?#]*)?(\?([^#]*))?(#(.*))?$/,
    PATH_MATCH = /^([^\?#]*)?(\?([^#]*))?(#(.*))?$/,
    HASH_MATCH = PATH_MATCH,
    DEFAULT_PORTS = {'http': 80, 'https': 443, 'ftp': 21};


/**
 * Encode path using encodeUriSegment, ignoring forward slashes
 *
 * @param {string} path Path to encode
 * @returns {string}
 */
function encodePath(path) {
  var segments = path.split('/'),
      i = segments.length;

  while (i--) {
    segments[i] = encodeUriSegment(segments[i]);
  }

  return segments.join('/');
}


function matchUrl(url, obj) {
  var match = URL_MATCH.exec(url),

  match = {
      protocol: match[1],
      host: match[3],
      port: parseInt(match[5]) || DEFAULT_PORTS[match[1]] || null,
      path: match[6] || '/',
      search: match[8],
      hash: match[10]
    };

  if (obj) {
    obj.$$protocol = match.protocol;
    obj.$$host = match.host;
    obj.$$port = match.port;
  }

  return match;
}


function composeProtocolHostPort(protocol, host, port) {
  return protocol + '://' + host + (port == DEFAULT_PORTS[protocol] ? '' : ':' + port);
}


function pathPrefixFromBase(basePath) {
  return basePath.substr(0, basePath.lastIndexOf('/'));
}


function convertToHtml5Url(url, basePath, hashPrefix) {
  var match = matchUrl(url);

  // already html5 url
  if (decodeURIComponent(match.path) != basePath || isUndefined(match.hash) ||
      match.hash.indexOf(hashPrefix) != 0) {
    return url;
  // convert hashbang url -> html5 url
  } else {
    return composeProtocolHostPort(match.protocol, match.host, match.port) +
           pathPrefixFromBase(basePath) + match.hash.substr(hashPrefix.length);
  }
}


function convertToHashbangUrl(url, basePath, hashPrefix) {
  var match = matchUrl(url);

  // already hashbang url
  if (decodeURIComponent(match.path) == basePath) {
    return url;
  // convert html5 url -> hashbang url
  } else {
    var search = match.search && '?' + match.search || '',
        hash = match.hash && '#' + match.hash || '',
        pathPrefix = pathPrefixFromBase(basePath),
        path = match.path.substr(pathPrefix.length);

    if (match.path.indexOf(pathPrefix) != 0) {
      throw 'Invalid url "' + url + '", missing path prefix "' + pathPrefix + '" !';
    }

    return composeProtocolHostPort(match.protocol, match.host, match.port) + basePath +
           '#' + hashPrefix + path + search + hash;
  }
}


/**
 * LocationUrl represents an url
 * This object is exposed as $location service when HTML5 mode is enabled and supported
 *
 * @constructor
 * @param {string} url HTML5 url
 * @param {string} pathPrefix
 */
function LocationUrl(url, pathPrefix) {
  pathPrefix = pathPrefix || '';

  /**
   * Parse given html5 (regular) url string into properties
   * @param {string} url HTML5 url
   * @private
   */
  this.$$parse = function(url) {
    var match = matchUrl(url, this);

    if (match.path.indexOf(pathPrefix) != 0) {
      throw 'Invalid url "' + url + '", missing path prefix "' + pathPrefix + '" !';
    }

    this.$$path = decodeURIComponent(match.path.substr(pathPrefix.length));
    this.$$search = parseKeyValue(match.search);
    this.$$hash = match.hash && decodeURIComponent(match.hash) || '';

    this.$$compose();
  },

  /**
   * Compose url and update `absUrl` property
   * @private
   */
  this.$$compose = function() {
    var search = toKeyValue(this.$$search),
        hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : '';

    this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash;
    this.$$absUrl = composeProtocolHostPort(this.$$protocol, this.$$host, this.$$port) +
                    pathPrefix + this.$$url;
  };

  this.$$parse(url);
}


/**
 * LocationHashbangUrl represents url
 * This object is exposed as $location service when html5 history api is disabled or not supported
 *
 * @constructor
 * @param {string} url Legacy url
 * @param {string} hashPrefix Prefix for hash part (containing path and search)
 */
function LocationHashbangUrl(url, hashPrefix) {
  var basePath;

  /**
   * Parse given hashbang url into properties
   * @param {string} url Hashbang url
   * @private
   */
  this.$$parse = function(url) {
    var match = matchUrl(url, this);

    if (match.hash && match.hash.indexOf(hashPrefix) != 0) {
      throw 'Invalid url "' + url + '", missing hash prefix "' + hashPrefix + '" !';
    }

    basePath = match.path + (match.search ? '?' + match.search : '');
    match = HASH_MATCH.exec((match.hash || '').substr(hashPrefix.length));
    if (match[1]) {
      this.$$path = (match[1].charAt(0) == '/' ? '' : '/') + decodeURIComponent(match[1]);
    } else {
      this.$$path = '';
    }

    this.$$search = parseKeyValue(match[3]);
    this.$$hash = match[5] && decodeURIComponent(match[5]) || '';

    this.$$compose();
  };

  /**
   * Compose hashbang url and update `absUrl` property
   * @private
   */
  this.$$compose = function() {
    var search = toKeyValue(this.$$search),
        hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : '';

    this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash;
    this.$$absUrl = composeProtocolHostPort(this.$$protocol, this.$$host, this.$$port) +
                    basePath + (this.$$url ? '#' + hashPrefix + this.$$url : '');
  };

  this.$$parse(url);
}


LocationUrl.prototype = LocationHashbangUrl.prototype = {

  /**
   * Has any change been replacing ?
   * @private
   */
  $$replace: false,

  /**
   * @ngdoc method
   * @name angular.service.$location#absUrl
   * @methodOf angular.service.$location
   *
   * @description
   * This method is getter only.
   *
   * Return full url representation with all segments encoded according to rules specified in
   * {@link http://www.ietf.org/rfc/rfc3986.txt RFC 3986}.
   *
   * @return {string}
   */
  absUrl: locationGetter('$$absUrl'),

  /**
   * @ngdoc method
   * @name angular.service.$location#url
   * @methodOf angular.service.$location
   *
   * @description
   * This method is getter / setter.
   *
   * Return url (e.g. `/path?a=b#hash`) when called without any parameter.
   *
   * Change path, search and hash, when called with parameter and return `$location`.
   *
   * @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`)
   * @return {string}
   */
  url: function(url, replace) {
    if (isUndefined(url))
      return this.$$url;

    var match = PATH_MATCH.exec(url);
    this.path(decodeURIComponent(match[1] || '')).search(match[3] || '')
        .hash(match[5] || '', replace);

    return this;
  },

  /**
   * @ngdoc method
   * @name angular.service.$location#protocol
   * @methodOf angular.service.$location
   *
   * @description
   * This method is getter only.
   *
   * Return protocol of current url.
   *
   * @return {string}
   */
  protocol: locationGetter('$$protocol'),

  /**
   * @ngdoc method
   * @name angular.service.$location#host
   * @methodOf angular.service.$location
   *
   * @description
   * This method is getter only.
   *
   * Return host of current url.
   *
   * @return {string}
   */
  host: locationGetter('$$host'),

  /**
   * @ngdoc method
   * @name angular.service.$location#port
   * @methodOf angular.service.$location
   *
   * @description
   * This method is getter only.
   *
   * Return port of current url.
   *
   * @return {Number}
   */
  port: locationGetter('$$port'),

  /**
   * @ngdoc method
   * @name angular.service.$location#path
   * @methodOf angular.service.$location
   *
   * @description
   * This method is getter / setter.
   *
   * Return path of current url when called without any parameter.
   *
   * Change path when called with parameter and return `$location`.
   *
   * Note: Path should always begin with forward slash (/), this method will add the forward slash
   * if it is missing.
   *
   * @param {string=} path New path
   * @return {string}
   */
  path: locationGetterSetter('$$path', function(path) {
    return path.charAt(0) == '/' ? path : '/' + path;
  }),

  /**
   * @ngdoc method
   * @name angular.service.$location#search
   * @methodOf angular.service.$location
   *
   * @description
   * This method is getter / setter.
   *
   * Return search part (as object) of current url when called without any parameter.
   *
   * Change search part when called with parameter and return `$location`.
   *
   * @param {string|object<string,string>=} search New search part - string or hash object
   * @return {string}
   */
  search: function(search, paramValue) {
    if (isUndefined(search))
      return this.$$search;

    if (isDefined(paramValue)) {
      if (paramValue === null) {
        delete this.$$search[search];
      } else {
        this.$$search[search] = encodeUriQuery(paramValue);
      }
    } else {
      this.$$search = isString(search) ? parseKeyValue(search) : search;
    }

    this.$$compose();
    return this;
  },

  /**
   * @ngdoc method
   * @name angular.service.$location#hash
   * @methodOf angular.service.$location
   *
   * @description
   * This method is getter / setter.
   *
   * Return hash fragment when called without any parameter.
   *
   * Change hash fragment when called with parameter and return `$location`.
   *
   * @param {string=} hash New hash fragment
   * @return {string}
   */
  hash: locationGetterSetter('$$hash', identity),

  /**
   * @ngdoc method
   * @name angular.service.$location#replace
   * @methodOf angular.service.$location
   *
   * @description
   * If called, all changes to $location during current `$digest` will be replacing current history
   * record, instead of adding new one.
   */
  replace: function() {
    this.$$replace = true;
    return this;
  }
};


function locationGetter(property) {
  return function() {
    return this[property];
  };
}


function locationGetterSetter(property, preprocess) {
  return function(value) {
    if (isUndefined(value))
      return this[property];

    this[property] = preprocess(value);
    this.$$compose();

    return this;
  };
}


/**
 * @ngdoc service
 * @name angular.service.$location
 *
 * @requires $browser
 * @requires $sniffer
 * @requires $locationConfig
 * @requires $document
 *
 * @description
 * The $location service parses the URL in the browser address bar (based on the {@link https://developer.mozilla.org/en/window.location window.location}) and makes the URL available to your application. Changes to the URL in the address bar are reflected into $location service and changes to $location are reflected into the browser address bar.
 *
 * **The $location service:**
 *
 * - Exposes the current URL in the browser address bar, so you can
 *   - Watch and observe the URL.
 *   - Change the URL.
 * - Synchronizes the URL with the browser when the user
 *   - Changes the address bar.
 *   - Clicks the back or forward button (or clicks a History link).
 *   - Clicks on a link.
 * - Represents the URL object as a set of methods (protocol, host, port, path, search, hash).
 *
 * For more information see {@link guide/dev_guide.services.$location Developer Guide: Angular Services: Using $location}
 */
angularServiceInject('$location', function($browser, $sniffer, $locationConfig, $document) {
  var scope = this, currentUrl,
      basePath = $browser.baseHref() || '/',
      pathPrefix = pathPrefixFromBase(basePath),
      hashPrefix = $locationConfig.hashPrefix || '',
      initUrl = $browser.url();

  if ($locationConfig.html5Mode) {
    if ($sniffer.history) {
      currentUrl = new LocationUrl(convertToHtml5Url(initUrl, basePath, hashPrefix), pathPrefix);
    } else {
      currentUrl = new LocationHashbangUrl(convertToHashbangUrl(initUrl, basePath, hashPrefix),
                                           hashPrefix);
    }

    // link rewriting
    var u = currentUrl,
        absUrlPrefix = composeProtocolHostPort(u.protocol(), u.host(), u.port()) + pathPrefix;

    $document.bind('click', function(event) {
      // TODO(vojta): rewrite link when opening in new tab/window (in legacy browser)
      // currently we open nice url link and redirect then

      if (event.ctrlKey || event.metaKey || event.which == 2) return;

      var elm = jqLite(event.target);

      // traverse the DOM up to find first A tag
      while (elm.length && lowercase(elm[0].nodeName) !== 'a') {
        elm = elm.parent();
      }

      var href = elm.attr('href');
      if (!href || isDefined(elm.attr('ng:ext-link')) || elm.attr('target')) return;

      // remove same domain from full url links (IE7 always returns full hrefs)
      href = href.replace(absUrlPrefix, '');

      // link to different domain (or base path)
      if (href.substr(0, 4) == 'http') return;

      // remove pathPrefix from absolute links
      href = href.indexOf(pathPrefix) === 0 ? href.substr(pathPrefix.length) : href;

      currentUrl.url(href);
      scope.$apply();
      event.preventDefault();
      // hack to work around FF6 bug 684208 when scenario runner clicks on links
      window.angular['ff-684208-preventDefault'] = true;
    });
  } else {
    currentUrl = new LocationHashbangUrl(initUrl, hashPrefix);
  }

  // rewrite hashbang url <> html5 url
  if (currentUrl.absUrl() != initUrl) {
    $browser.url(currentUrl.absUrl(), true);
  }

  // update $location when $browser url changes
  $browser.onUrlChange(function(newUrl) {
    if (currentUrl.absUrl() != newUrl) {
      currentUrl.$$parse(newUrl);
      scope.$apply();
    }
  });

  // update browser
  var changeCounter = 0;
  scope.$watch(function() {
    if ($browser.url() != currentUrl.absUrl()) {
      changeCounter++;
      scope.$evalAsync(function() {
        $browser.url(currentUrl.absUrl(), currentUrl.$$replace);
        currentUrl.$$replace = false;
      });
    }

    return changeCounter;
  });

  return currentUrl;
}, ['$browser', '$sniffer', '$locationConfig', '$document']);


angular.service('$locationConfig', function() {
  return {
    html5Mode: false,
    hashPrefix: ''
  };
});
