/** @module url */ /** for typedoc */
import {extend, bindFunctions} from "../common/common";
import {isFunction, isString, isDefined, isArray} from "../common/predicates";
import {UrlMatcher} from "./module";
import {services} from "../common/coreservices";
import {UrlMatcherFactory} from "./urlMatcherFactory";
import {StateParams} from "../params/stateParams";

let $location = services.location;

// Returns a string that is a prefix of all strings matching the RegExp
function regExpPrefix(re) {
  let prefix = /^\^((?:\\[^a-zA-Z0-9]|[^\\\[\]\^$*+?.()|{}]+)*)/.exec(re.source);
  return (prefix != null) ? prefix[1].replace(/\\(.)/g, "$1") : '';
}

// Interpolates matched values into a String.replace()-style pattern
function interpolate(pattern, match) {
  return pattern.replace(/\$(\$|\d{1,2})/, function (m, what) {
    return match[what === '$' ? 0 : Number(what)];
  });
}

function handleIfMatch($injector, $stateParams, handler, match) {
  if (!match) return false;
  let result = $injector.invoke(handler, handler, { $match: match, $stateParams: $stateParams });
  return isDefined(result) ? result : true;
}

function appendBasePath(url, isHtml5, absolute) {
  let baseHref = services.locationConfig.baseHref();
  if (baseHref === '/') return url;
  if (isHtml5) return baseHref.slice(0, -1) + url;
  if (absolute) return baseHref.slice(1) + url;
  return url;
}

// TODO: Optimize groups of rules with non-empty prefix into some sort of decision tree
function update(rules: Function[], otherwiseFn: Function, evt?: any) {
  if (evt && evt.defaultPrevented) return;

  function check(rule) {
    let handled = rule(services.$injector, $location);

    if (!handled) return false;
    if (isString(handled)) {
      $location.replace();
      $location.url(handled);
    }
    return true;
  }
  let n = rules.length, i;

  for (i = 0; i < n; i++) {
    if (check(rules[i])) return;
  }
  // always check otherwise last to allow dynamic updates to the set of rules
  if (otherwiseFn) check(otherwiseFn);
}


/**
 * @ngdoc object
 * @name ui.router.router.$urlRouterProvider
 *
 * @requires ui.router.util.$urlMatcherFactoryProvider
 * @requires $locationProvider
 *
 * @description
 * `$urlRouterProvider` has the responsibility of watching `$location`.
 * When `$location` changes it runs through a list of rules one by one until a
 * match is found. `$urlRouterProvider` is used behind the scenes anytime you specify
 * a url in a state configuration. All urls are compiled into a UrlMatcher object.
 *
 * There are several methods on `$urlRouterProvider` that make it useful to use directly
 * in your module config.
 */
export class UrlRouterProvider {
  /** @hidden */
  rules = [];
  /** @hidden */
  otherwiseFn: Function = null;
  /** @hidden */
  interceptDeferred = false;

  constructor(private $urlMatcherFactory: UrlMatcherFactory, private $stateParams: StateParams) {

  }

  /**
   * @ngdoc function
   * @name ui.router.router.$urlRouterProvider#rule
   * @methodOf ui.router.router.$urlRouterProvider
   *
   * @description
   * Defines rules that are used by `$urlRouterProvider` to find matches for
   * specific URLs.
   *
   * @example
   * <pre>
   * var app = angular.module('app', ['ui.router.router']);
   *
   * app.config(function ($urlRouterProvider) {
   *   // Here's an example of how you might allow case insensitive urls
   *   $urlRouterProvider.rule(function ($injector, $location) {
   *     var path = $location.path(),
   *         normalized = path.toLowerCase();
   *
   *     if (path !== normalized) {
   *       return normalized;
   *     }
   *   });
   * });
   * </pre>
   *
   * @param {function} rule Handler function that takes `$injector` and `$location`
   * services as arguments. You can use them to return a valid path as a string.
   *
   * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance
   */
  rule(rule) {
    if (!isFunction(rule)) throw new Error("'rule' must be a function");
    this.rules.push(rule);
    return this;
  };

  /**
   * @ngdoc object
   * @name ui.router.router.$urlRouterProvider#otherwise
   * @methodOf ui.router.router.$urlRouterProvider
   *
   * @description
   * Defines a path that is used when an invalid route is requested.
   *
   * @example
   * <pre>
   * var app = angular.module('app', ['ui.router.router']);
   *
   * app.config(function ($urlRouterProvider) {
   *   // if the path doesn't match any of the urls you configured
   *   // otherwise will take care of routing the user to the
   *   // specified url
   *   $urlRouterProvider.otherwise('/index');
   *
   *   // Example of using function rule as param
   *   $urlRouterProvider.otherwise(function ($injector, $location) {
   *     return '/a/valid/url';
   *   });
   * });
   * </pre>
   *
   * @param {string|function} rule The url path you want to redirect to or a function
   * rule that returns the url path. The function version is passed two params:
   * `$injector` and `$location` services, and must return a url string.
   *
   * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance
   */
  otherwise(rule) {
    if (!isFunction(rule) && !isString(rule)) throw new Error("'rule' must be a string or function");
    this.otherwiseFn = isString(rule) ? () => rule : rule;
    return this;
  };

  /**
   * @ngdoc function
   * @name ui.router.router.$urlRouterProvider#when
   * @methodOf ui.router.router.$urlRouterProvider
   *
   * @description
   * Registers a handler for a given url matching. 
   * 
   * If the handler is a string, it is
   * treated as a redirect, and is interpolated according to the syntax of match
   * (i.e. like `String.replace()` for `RegExp`, or like a `UrlMatcher` pattern otherwise).
   *
   * If the handler is a function, it is injectable. It gets invoked if `$location`
   * matches. You have the option of inject the match object as `$match`.
   *
   * The handler can return
   *
   * - **falsy** to indicate that the rule didn't match after all, then `$urlRouter`
   *   will continue trying to find another one that matches.
   * - **string** which is treated as a redirect and passed to `$location.url()`
   * - **void** or any **truthy** value tells `$urlRouter` that the url was handled.
   *
   * @example
   * <pre>
   * var app = angular.module('app', ['ui.router.router']);
   *
   * app.config(function ($urlRouterProvider) {
   *   $urlRouterProvider.when($state.url, function ($match, $stateParams) {
   *     if ($state.$current.navigable !== state ||
   *         !equalForKeys($match, $stateParams) {
   *      $state.transitionTo(state, $match, false);
   *     }
   *   });
   * });
   * </pre>
   *
   * @param {string|object} what The incoming path that you want to redirect.
   * @param {string|function} handler The path you want to redirect your user to.
   */
  when(what, handler) {
    let {$urlMatcherFactory, $stateParams} = this;
    let redirect, handlerIsString = isString(handler);

    // @todo Queue this
    if (isString(what)) what = $urlMatcherFactory.compile(what);

    if (!handlerIsString && !isFunction(handler) && !isArray(handler))
      throw new Error("invalid 'handler' in when()");

    let strategies = {
      matcher: function (_what, _handler) {
        if (handlerIsString) {
          redirect = $urlMatcherFactory.compile(_handler);
          _handler = ['$match', redirect.format.bind(redirect)];
        }
        return extend(function () {
          return handleIfMatch(services.$injector, $stateParams, _handler, _what.exec($location.path(), $location.search(), $location.hash()));
        }, {
          prefix: isString(_what.prefix) ? _what.prefix : ''
        });
      },
      regex: function (_what, _handler) {
        if (_what.global || _what.sticky) throw new Error("when() RegExp must not be global or sticky");

        if (handlerIsString) {
          redirect = _handler;
          _handler = ['$match', ($match) => interpolate(redirect, $match)];
        }
        return extend(function () {
          return handleIfMatch(services.$injector, $stateParams, _handler, _what.exec($location.path()));
        }, {
          prefix: regExpPrefix(_what)
        });
      }
    };

    let check = {
      matcher: $urlMatcherFactory.isMatcher(what),
      regex: what instanceof RegExp
    };

    for (var n in check) {
      if (check[n]) return this.rule(strategies[n](what, handler));
    }

    throw new Error("invalid 'what' in when()");
  };

  /**
   * @ngdoc function
   * @name ui.router.router.$urlRouterProvider#deferIntercept
   * @methodOf ui.router.router.$urlRouterProvider
   *
   * @description
   * Disables (or enables) deferring location change interception.
   *
   * If you wish to customize the behavior of syncing the URL (for example, if you wish to
   * defer a transition but maintain the current URL), call this method at configuration time.
   * Then, at run time, call `$urlRouter.listen()` after you have configured your own
   * `$locationChangeSuccess` event handler.
   *
   * @example
   * <pre>
   * var app = angular.module('app', ['ui.router.router']);
   *
   * app.config(function ($urlRouterProvider) {
   *
   *   // Prevent $urlRouter from automatically intercepting URL changes;
   *   // this allows you to configure custom behavior in between
   *   // location changes and route synchronization:
   *   $urlRouterProvider.deferIntercept();
   *
   * }).run(function ($rootScope, $urlRouter, UserService) {
   *
   *   $rootScope.$on('$locationChangeSuccess', function(e) {
   *     // UserService is an example service for managing user state
   *     if (UserService.isLoggedIn()) return;
   *
   *     // Prevent $urlRouter's default handler from firing
   *     e.preventDefault();
   *
   *     UserService.handleLogin().then(function() {
   *       // Once the user has logged in, sync the current URL
   *       // to the router:
   *       $urlRouter.sync();
   *     });
   *   });
   *
   *   // Configures $urlRouter's listener *after* your custom listener
   *   $urlRouter.listen();
   * });
   * </pre>
   *
   * @param {boolean} defer Indicates whether to defer location change interception. Passing
   *        no parameter is equivalent to `true`.
   */
  deferIntercept(defer) {
    if (defer === undefined) defer = true;
    this.interceptDeferred = defer;
  };
}

export class UrlRouter {
  private location: string;
  private listener: Function;

  constructor(private urlRouterProvider: UrlRouterProvider) {
    bindFunctions(UrlRouter.prototype, this, this);
  }

  /**
   * @ngdoc function
   * @name ui.router.router.$urlRouter#sync
   * @methodOf ui.router.router.$urlRouter
   *
   * @description
   * Triggers an update; the same update that happens when the address bar url changes, aka `$locationChangeSuccess`.
   * This method is useful when you need to use `preventDefault()` on the `$locationChangeSuccess` event,
   * perform some custom logic (route protection, auth, config, redirection, etc) and then finally proceed
   * with the transition by calling `$urlRouter.sync()`.
   *
   * @example
   * <pre>
   * angular.module('app', ['ui.router'])
   *   .run(function($rootScope, $urlRouter) {
   *     $rootScope.$on('$locationChangeSuccess', function(evt) {
   *       // Halt state change from even starting
   *       evt.preventDefault();
   *       // Perform custom logic
   *       var meetsRequirement = ...
   *       // Continue with the update and state transition if logic allows
   *       if (meetsRequirement) $urlRouter.sync();
   *     });
   * });
   * </pre>
   */
  sync() {
    update(this.urlRouterProvider.rules, this.urlRouterProvider.otherwiseFn);
  }

  listen() {
    return this.listener = this.listener || $location.onChange(evt => update(this.urlRouterProvider.rules, this.urlRouterProvider.otherwiseFn, evt));
  }

  update(read?) {
    if (read) {
      this.location = $location.url();
      return;
    }
    if ($location.url() === this.location) return;

    $location.url(this.location);
    $location.replace();
  }

  push(urlMatcher, params, options) {
    $location.url(urlMatcher.format(params || {}));
    if (options && options.replace) $location.replace();
  }

  /**
   * @ngdoc function
   * @name ui.router.router.$urlRouter#href
   * @methodOf ui.router.router.$urlRouter
   *
   * @description
   * A URL generation method that returns the compiled URL for a given
   * {@link ui.router.util.type:UrlMatcher `UrlMatcher`}, populated with the provided parameters.
   *
   * @example
   * <pre>
   * $bob = $urlRouter.href(new UrlMatcher("/about/:person"), {
   *   person: "bob"
   * });
   * // $bob == "/about/bob";
   * </pre>
   *
   * @param {UrlMatcher} urlMatcher The `UrlMatcher` object which is used as the template of the URL to generate.
   * @param {object=} params An object of parameter values to fill the matcher's required parameters.
   * @param {object=} options Options object. The options are:
   *
   * - **`absolute`** - {boolean=false},  If true will generate an absolute url, e.g. "http://www.example.com/fullurl".
   *
   * @returns {string} Returns the fully compiled URL, or `null` if `params` fail validation against `urlMatcher`
   */
  href(urlMatcher: UrlMatcher, params: any, options: any): string {
    if (!urlMatcher.validates(params)) return null;

    let url = urlMatcher.format(params);
    options = options || {};

    let cfg = services.locationConfig;
    let isHtml5 = cfg.html5Mode();
    if (!isHtml5 && url !== null) {
      url = "#" + cfg.hashPrefix() + url;
    }
    url = appendBasePath(url, isHtml5, options.absolute);

    if (!options.absolute || !url) {
      return url;
    }

    let slash = (!isHtml5 && url ? '/' : ''), port = cfg.port();
    port = <any> (port === 80 || port === 443 ? '' : ':' + port);

    return [cfg.protocol(), '://', cfg.host(), port, slash, url].join('');
  }
}

