/*
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 *
*/

/* global unescape */

(function(window, undefined) {
  'use strict';

  /* jshint validthis:true */
  function L10nError(message, id, loc) {
    this.name = 'L10nError';
    this.message = message;
    this.id = id;
    this.loc = loc;
  }
  L10nError.prototype = Object.create(Error.prototype);
  L10nError.prototype.constructor = L10nError;


  /* jshint browser:true */

  var io = {
    load: function load(url, callback, sync) {
      var xhr = new XMLHttpRequest();

      if (xhr.overrideMimeType) {
        xhr.overrideMimeType('text/plain');
      }

      xhr.open('GET', url, !sync);

      xhr.addEventListener('load', function io_load(e) {
        if (e.target.status === 200 || e.target.status === 0) {
          callback(null, e.target.responseText);
        } else {
          callback(new L10nError('Not found: ' + url));
        }
      });
      xhr.addEventListener('error', callback);
      xhr.addEventListener('timeout', callback);

      // the app: protocol throws on 404, see https://bugzil.la/827243
      try {
        xhr.send(null);
      } catch (e) {
        callback(new L10nError('Not found: ' + url));
      }
    },

    loadJSON: function loadJSON(url, callback) {
      var xhr = new XMLHttpRequest();

      if (xhr.overrideMimeType) {
        xhr.overrideMimeType('application/json');
      }

      xhr.open('GET', url);

      xhr.responseType = 'json';
      xhr.addEventListener('load', function io_loadjson(e) {
        if (e.target.status === 200 || e.target.status === 0) {
          callback(null, e.target.response);
        } else {
          callback(new L10nError('Not found: ' + url));
        }
      });
      xhr.addEventListener('error', callback);
      xhr.addEventListener('timeout', callback);

      // the app: protocol throws on 404, see https://bugzil.la/827243
      try {
        xhr.send(null);
      } catch (e) {
        callback(new L10nError('Not found: ' + url));
      }
    }
  };

  function EventEmitter() {}

  EventEmitter.prototype.emit = function ee_emit() {
    if (!this._listeners) {
      return;
    }

    var args = Array.prototype.slice.call(arguments);
    var type = args.shift();
    if (!this._listeners[type]) {
      return;
    }

    var typeListeners = this._listeners[type].slice();
    for (var i = 0; i < typeListeners.length; i++) {
      typeListeners[i].apply(this, args);
    }
  };

  EventEmitter.prototype.addEventListener = function ee_add(type, listener) {
    if (!this._listeners) {
      this._listeners = {};
    }
    if (!(type in this._listeners)) {
      this._listeners[type] = [];
    }
    this._listeners[type].push(listener);
  };

  EventEmitter.prototype.removeEventListener = function ee_rm(type, listener) {
    if (!this._listeners) {
      return;
    }

    var typeListeners = this._listeners[type];
    var pos = typeListeners.indexOf(listener);
    if (pos === -1) {
      return;
    }

    typeListeners.splice(pos, 1);
  };


  function getPluralRule(lang) {
    var locales2rules = {
      'af': 3,
      'ak': 4,
      'am': 4,
      'ar': 1,
      'asa': 3,
      'az': 0,
      'be': 11,
      'bem': 3,
      'bez': 3,
      'bg': 3,
      'bh': 4,
      'bm': 0,
      'bn': 3,
      'bo': 0,
      'br': 20,
      'brx': 3,
      'bs': 11,
      'ca': 3,
      'cgg': 3,
      'chr': 3,
      'cs': 12,
      'cy': 17,
      'da': 3,
      'de': 3,
      'dv': 3,
      'dz': 0,
      'ee': 3,
      'el': 3,
      'en': 3,
      'eo': 3,
      'es': 3,
      'et': 3,
      'eu': 3,
      'fa': 0,
      'ff': 5,
      'fi': 3,
      'fil': 4,
      'fo': 3,
      'fr': 5,
      'fur': 3,
      'fy': 3,
      'ga': 8,
      'gd': 24,
      'gl': 3,
      'gsw': 3,
      'gu': 3,
      'guw': 4,
      'gv': 23,
      'ha': 3,
      'haw': 3,
      'he': 2,
      'hi': 4,
      'hr': 11,
      'hu': 0,
      'id': 0,
      'ig': 0,
      'ii': 0,
      'is': 3,
      'it': 3,
      'iu': 7,
      'ja': 0,
      'jmc': 3,
      'jv': 0,
      'ka': 0,
      'kab': 5,
      'kaj': 3,
      'kcg': 3,
      'kde': 0,
      'kea': 0,
      'kk': 3,
      'kl': 3,
      'km': 0,
      'kn': 0,
      'ko': 0,
      'ksb': 3,
      'ksh': 21,
      'ku': 3,
      'kw': 7,
      'lag': 18,
      'lb': 3,
      'lg': 3,
      'ln': 4,
      'lo': 0,
      'lt': 10,
      'lv': 6,
      'mas': 3,
      'mg': 4,
      'mk': 16,
      'ml': 3,
      'mn': 3,
      'mo': 9,
      'mr': 3,
      'ms': 0,
      'mt': 15,
      'my': 0,
      'nah': 3,
      'naq': 7,
      'nb': 3,
      'nd': 3,
      'ne': 3,
      'nl': 3,
      'nn': 3,
      'no': 3,
      'nr': 3,
      'nso': 4,
      'ny': 3,
      'nyn': 3,
      'om': 3,
      'or': 3,
      'pa': 3,
      'pap': 3,
      'pl': 13,
      'ps': 3,
      'pt': 3,
      'rm': 3,
      'ro': 9,
      'rof': 3,
      'ru': 11,
      'rwk': 3,
      'sah': 0,
      'saq': 3,
      'se': 7,
      'seh': 3,
      'ses': 0,
      'sg': 0,
      'sh': 11,
      'shi': 19,
      'sk': 12,
      'sl': 14,
      'sma': 7,
      'smi': 7,
      'smj': 7,
      'smn': 7,
      'sms': 7,
      'sn': 3,
      'so': 3,
      'sq': 3,
      'sr': 11,
      'ss': 3,
      'ssy': 3,
      'st': 3,
      'sv': 3,
      'sw': 3,
      'syr': 3,
      'ta': 3,
      'te': 3,
      'teo': 3,
      'th': 0,
      'ti': 4,
      'tig': 3,
      'tk': 3,
      'tl': 4,
      'tn': 3,
      'to': 0,
      'tr': 0,
      'ts': 3,
      'tzm': 22,
      'uk': 11,
      'ur': 3,
      've': 3,
      'vi': 0,
      'vun': 3,
      'wa': 4,
      'wae': 3,
      'wo': 0,
      'xh': 3,
      'xog': 3,
      'yo': 0,
      'zh': 0,
      'zu': 3
    };

    // utility functions for plural rules methods
    function isIn(n, list) {
      return list.indexOf(n) !== -1;
    }
    function isBetween(n, start, end) {
      return typeof n === typeof start && start <= n && n <= end;
    }

    // list of all plural rules methods:
    // map an integer to the plural form name to use
    var pluralRules = {
      '0': function() {
        return 'other';
      },
      '1': function(n) {
        if ((isBetween((n % 100), 3, 10))) {
          return 'few';
        }
        if (n === 0) {
          return 'zero';
        }
        if ((isBetween((n % 100), 11, 99))) {
          return 'many';
        }
        if (n === 2) {
          return 'two';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '2': function(n) {
        if (n !== 0 && (n % 10) === 0) {
          return 'many';
        }
        if (n === 2) {
          return 'two';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '3': function(n) {
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '4': function(n) {
        if ((isBetween(n, 0, 1))) {
          return 'one';
        }
        return 'other';
      },
      '5': function(n) {
        if ((isBetween(n, 0, 2)) && n !== 2) {
          return 'one';
        }
        return 'other';
      },
      '6': function(n) {
        if (n === 0) {
          return 'zero';
        }
        if ((n % 10) === 1 && (n % 100) !== 11) {
          return 'one';
        }
        return 'other';
      },
      '7': function(n) {
        if (n === 2) {
          return 'two';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '8': function(n) {
        if ((isBetween(n, 3, 6))) {
          return 'few';
        }
        if ((isBetween(n, 7, 10))) {
          return 'many';
        }
        if (n === 2) {
          return 'two';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '9': function(n) {
        if (n === 0 || n !== 1 && (isBetween((n % 100), 1, 19))) {
          return 'few';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '10': function(n) {
        if ((isBetween((n % 10), 2, 9)) && !(isBetween((n % 100), 11, 19))) {
          return 'few';
        }
        if ((n % 10) === 1 && !(isBetween((n % 100), 11, 19))) {
          return 'one';
        }
        return 'other';
      },
      '11': function(n) {
        if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) {
          return 'few';
        }
        if ((n % 10) === 0 ||
            (isBetween((n % 10), 5, 9)) ||
            (isBetween((n % 100), 11, 14))) {
          return 'many';
        }
        if ((n % 10) === 1 && (n % 100) !== 11) {
          return 'one';
        }
        return 'other';
      },
      '12': function(n) {
        if ((isBetween(n, 2, 4))) {
          return 'few';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '13': function(n) {
        if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) {
          return 'few';
        }
        if (n !== 1 && (isBetween((n % 10), 0, 1)) ||
            (isBetween((n % 10), 5, 9)) ||
            (isBetween((n % 100), 12, 14))) {
          return 'many';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '14': function(n) {
        if ((isBetween((n % 100), 3, 4))) {
          return 'few';
        }
        if ((n % 100) === 2) {
          return 'two';
        }
        if ((n % 100) === 1) {
          return 'one';
        }
        return 'other';
      },
      '15': function(n) {
        if (n === 0 || (isBetween((n % 100), 2, 10))) {
          return 'few';
        }
        if ((isBetween((n % 100), 11, 19))) {
          return 'many';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '16': function(n) {
        if ((n % 10) === 1 && n !== 11) {
          return 'one';
        }
        return 'other';
      },
      '17': function(n) {
        if (n === 3) {
          return 'few';
        }
        if (n === 0) {
          return 'zero';
        }
        if (n === 6) {
          return 'many';
        }
        if (n === 2) {
          return 'two';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '18': function(n) {
        if (n === 0) {
          return 'zero';
        }
        if ((isBetween(n, 0, 2)) && n !== 0 && n !== 2) {
          return 'one';
        }
        return 'other';
      },
      '19': function(n) {
        if ((isBetween(n, 2, 10))) {
          return 'few';
        }
        if ((isBetween(n, 0, 1))) {
          return 'one';
        }
        return 'other';
      },
      '20': function(n) {
        if ((isBetween((n % 10), 3, 4) || ((n % 10) === 9)) && !(
            isBetween((n % 100), 10, 19) ||
            isBetween((n % 100), 70, 79) ||
            isBetween((n % 100), 90, 99)
            )) {
          return 'few';
        }
        if ((n % 1000000) === 0 && n !== 0) {
          return 'many';
        }
        if ((n % 10) === 2 && !isIn((n % 100), [12, 72, 92])) {
          return 'two';
        }
        if ((n % 10) === 1 && !isIn((n % 100), [11, 71, 91])) {
          return 'one';
        }
        return 'other';
      },
      '21': function(n) {
        if (n === 0) {
          return 'zero';
        }
        if (n === 1) {
          return 'one';
        }
        return 'other';
      },
      '22': function(n) {
        if ((isBetween(n, 0, 1)) || (isBetween(n, 11, 99))) {
          return 'one';
        }
        return 'other';
      },
      '23': function(n) {
        if ((isBetween((n % 10), 1, 2)) || (n % 20) === 0) {
          return 'one';
        }
        return 'other';
      },
      '24': function(n) {
        if ((isBetween(n, 3, 10) || isBetween(n, 13, 19))) {
          return 'few';
        }
        if (isIn(n, [2, 12])) {
          return 'two';
        }
        if (isIn(n, [1, 11])) {
          return 'one';
        }
        return 'other';
      }
    };

    // return a function that gives the plural form name for a given integer
    var index = locales2rules[lang.replace(/-.*$/, '')];
    if (!(index in pluralRules)) {
      return function() { return 'other'; };
    }
    return pluralRules[index];
  }




  var parsePatterns;

  function parse(ctx, source) {
    var ast = {};

    if (!parsePatterns) {
      parsePatterns = {
        comment: /^\s*#|^\s*$/,
        entity: /^([^=\s]+)\s*=\s*(.+)$/,
        multiline: /[^\\]\\$/,
        macro: /\{\[\s*(\w+)\(([^\)]*)\)\s*\]\}/i,
        unicode: /\\u([0-9a-fA-F]{1,4})/g,
        entries: /[\r\n]+/,
        controlChars: /\\([\\\n\r\t\b\f\{\}\"\'])/g
      };
    }

    var entries = source.split(parsePatterns.entries);
    for (var i = 0; i < entries.length; i++) {
      var line = entries[i];

      if (parsePatterns.comment.test(line)) {
        continue;
      }

      while (parsePatterns.multiline.test(line) && i < entries.length) {
        line = line.slice(0, -1) + entries[++i].trim();
      }

      var entityMatch = line.match(parsePatterns.entity);
      if (entityMatch) {
        try {
          parseEntity(entityMatch[1], entityMatch[2], ast);
        } catch (e) {
          if (ctx) {
            ctx._emitter.emit('error', e);
          } else {
            throw e;
          }
        }
      }
    }
    return ast;
  }

  function setEntityValue(id, attr, key, value, ast) {
    var obj = ast;
    var prop = id;

    if (attr) {
      if (!(id in obj)) {
        obj[id] = {};
      }
      if (typeof(obj[id]) === 'string') {
        obj[id] = {'_': obj[id]};
      }
      obj = obj[id];
      prop = attr;
    }

    if (!key) {
      obj[prop] = value;
      return;
    }

    if (!(prop in obj)) {
      obj[prop] = {'_': {}};
    } else if (typeof(obj[prop]) === 'string') {
      obj[prop] = {'_index': parseMacro(obj[prop]), '_': {}};
    }
    obj[prop]._[key] = value;
  }

  function parseEntity(id, value, ast) {
    var name, key;

    var pos = id.indexOf('[');
    if (pos !== -1) {
      name = id.substr(0, pos);
      key = id.substring(pos + 1, id.length - 1);
    } else {
      name = id;
      key = null;
    }

    var nameElements = name.split('.');

    if (nameElements.length > 2) {
      throw new Error('Error in ID: "' + name + '".' +
                      ' Nested attributes are not supported.');
    }

    var attr;
    if (nameElements.length > 1) {
      name = nameElements[0];
      attr = nameElements[1];
    } else {
      attr = null;
    }

    setEntityValue(name, attr, key, unescapeString(value), ast);
  }

  function unescapeControlCharacters(str) {
    return str.replace(parsePatterns.controlChars, '$1');
  }

  function unescapeUnicode(str) {
    return str.replace(parsePatterns.unicode, function(match, token) {
      return unescape('%u' + '0000'.slice(token.length) + token);
    });
  }

  function unescapeString(str) {
    if (str.lastIndexOf('\\') !== -1) {
      str = unescapeControlCharacters(str);
    }
    return unescapeUnicode(str);
  }

  function parseMacro(str) {
    var match = str.match(parsePatterns.macro);
    if (!match) {
      throw new L10nError('Malformed macro');
    }
    return [match[1], match[2]];
  }



  var MAX_PLACEABLE_LENGTH = 2500;
  var MAX_PLACEABLES = 100;
  var rePlaceables = /\{\{\s*(.+?)\s*\}\}/g;

  function Entity(id, node, env) {
    this.id = id;
    this.env = env;
    // the dirty guard prevents cyclic or recursive references from other
    // Entities; see Entity.prototype.resolve
    this.dirty = false;
    if (typeof node === 'string') {
      this.value = node;
    } else {
      // it's either a hash or it has attrs, or both
      for (var key in node) {
        if (node.hasOwnProperty(key) && key[0] !== '_') {
          if (!this.attributes) {
            this.attributes = {};
          }
          this.attributes[key] = new Entity(this.id + '.' + key, node[key],
                                            env);
        }
      }
      this.value = node._ || null;
      this.index = node._index;
    }
  }

  Entity.prototype.resolve = function E_resolve(ctxdata) {
    if (this.dirty) {
      return undefined;
    }

    this.dirty = true;
    var val;
    // if resolve fails, we want the exception to bubble up and stop the whole
    // resolving process;  however, we still need to clean up the dirty flag
    try {
      val = resolve(ctxdata, this.env, this.value, this.index);
    } finally {
      this.dirty = false;
    }
    return val;
  };

  Entity.prototype.toString = function E_toString(ctxdata) {
    try {
      return this.resolve(ctxdata);
    } catch (e) {
      return undefined;
    }
  };

  Entity.prototype.valueOf = function E_valueOf(ctxdata) {
    if (!this.attributes) {
      return this.toString(ctxdata);
    }

    var entity = {
      value: this.toString(ctxdata),
      attributes: {}
    };

    for (var key in this.attributes) {
      if (this.attributes.hasOwnProperty(key)) {
        entity.attributes[key] = this.attributes[key].toString(ctxdata);
      }
    }

    return entity;
  };

  function subPlaceable(ctxdata, env, match, id) {
    if (ctxdata && ctxdata.hasOwnProperty(id) &&
        (typeof ctxdata[id] === 'string' ||
         (typeof ctxdata[id] === 'number' && !isNaN(ctxdata[id])))) {
      return ctxdata[id];
    }

    if (env.hasOwnProperty(id)) {
      if (!(env[id] instanceof Entity)) {
        env[id] = new Entity(id, env[id], env);
      }
      var value = env[id].resolve(ctxdata);
      if (typeof value === 'string') {
        // prevent Billion Laughs attacks
        if (value.length >= MAX_PLACEABLE_LENGTH) {
          throw new L10nError('Too many characters in placeable (' +
                              value.length + ', max allowed is ' +
                              MAX_PLACEABLE_LENGTH + ')');
        }
        return value;
      }
    }
    return match;
  }

  function interpolate(ctxdata, env, str) {
    var placeablesCount = 0;
    var value = str.replace(rePlaceables, function(match, id) {
      // prevent Quadratic Blowup attacks
      if (placeablesCount++ >= MAX_PLACEABLES) {
        throw new L10nError('Too many placeables (' + placeablesCount +
                            ', max allowed is ' + MAX_PLACEABLES + ')');
      }
      return subPlaceable(ctxdata, env, match, id);
    });
    placeablesCount = 0;
    return value;
  }

  function resolve(ctxdata, env, expr, index) {
    if (typeof expr === 'string') {
      return interpolate(ctxdata, env, expr);
    }

    if (typeof expr === 'boolean' ||
        typeof expr === 'number' ||
        !expr) {
      return expr;
    }

    // otherwise, it's a dict

    if (index && ctxdata && ctxdata.hasOwnProperty(index[1])) {
      var argValue = ctxdata[index[1]];

      // special cases for zero, one, two if they are defined on the hash
      if (argValue === 0 && 'zero' in expr) {
        return resolve(ctxdata, env, expr.zero);
      }
      if (argValue === 1 && 'one' in expr) {
        return resolve(ctxdata, env, expr.one);
      }
      if (argValue === 2 && 'two' in expr) {
        return resolve(ctxdata, env, expr.two);
      }

      var selector = env.__plural(argValue);
      if (expr.hasOwnProperty(selector)) {
        return resolve(ctxdata, env, expr[selector]);
      }
    }

    // if there was no index or no selector was found, try 'other'
    if ('other' in expr) {
      return resolve(ctxdata, env, expr.other);
    }

    return undefined;
  }

  function compile(env, ast) {
    env = env || {};
    for (var id in ast) {
      if (ast.hasOwnProperty(id)) {
        env[id] = new Entity(id, ast[id], env);
      }
    }
    return env;
  }



  function Locale(id, ctx) {
    this.id = id;
    this.ctx = ctx;
    this.isReady = false;
    this.entries = {
      __plural: getPluralRule(id)
    };
  }

  Locale.prototype.getEntry = function L_getEntry(id) {
    /* jshint -W093 */

    var entries = this.entries;

    if (!entries.hasOwnProperty(id)) {
      return undefined;
    }

    if (entries[id] instanceof Entity) {
      return entries[id];
    }

    return entries[id] = new Entity(id, entries[id], entries);
  };

  Locale.prototype.build = function L_build(callback) {
    var sync = !callback;
    var ctx = this.ctx;
    var self = this;

    var l10nLoads = ctx.resLinks.length;

    function onL10nLoaded(err) {
      if (err) {
        ctx._emitter.emit('error', err);
      }
      if (--l10nLoads <= 0) {
        self.isReady = true;
        if (callback) {
          callback();
        }
      }
    }

    if (l10nLoads === 0) {
      onL10nLoaded();
      return;
    }

    function onJSONLoaded(err, json) {
      if (!err && json) {
        self.addAST(json);
      }
      onL10nLoaded(err);
    }

    function onPropLoaded(err, source) {
      if (!err && source) {
        var ast = parse(ctx, source);
        self.addAST(ast);
      }
      onL10nLoaded(err);
    }


    for (var i = 0; i < ctx.resLinks.length; i++) {
      var path = ctx.resLinks[i].replace('{{locale}}', this.id);
      var type = path.substr(path.lastIndexOf('.') + 1);

      switch (type) {
        case 'json':
          io.loadJSON(path, onJSONLoaded, sync);
          break;
        case 'properties':
          io.load(path, onPropLoaded, sync);
          break;
      }
    }
  };

  Locale.prototype.addAST = function(ast) {
    for (var id in ast) {
      if (ast.hasOwnProperty(id)) {
        this.entries[id] = ast[id];
      }
    }
  };

  Locale.prototype.getEntity = function(id, ctxdata) {
    var entry = this.getEntry(id);

    if (!entry) {
      return null;
    }
    return entry.valueOf(ctxdata);
  };



  function Context(id) {

    this.id = id;
    this.isReady = false;
    this.isLoading = false;

    this.supportedLocales = [];
    this.resLinks = [];
    this.locales = {};

    this._emitter = new EventEmitter();


    // Getting translations

    function getWithFallback(id) {
      /* jshint -W084 */

      if (!this.isReady) {
        throw new L10nError('Context not ready');
      }

      var cur = 0;
      var loc;
      var locale;
      while (loc = this.supportedLocales[cur]) {
        locale = this.getLocale(loc);
        if (!locale.isReady) {
          // build without callback, synchronously
          locale.build(null);
        }
        var entry = locale.getEntry(id);
        if (entry === undefined) {
          cur++;
          warning.call(this, new L10nError(id + ' not found in ' + loc, id,
                                           loc));
          continue;
        }
        return entry;
      }

      error.call(this, new L10nError(id + ' not found', id));
      return null;
    }

    this.get = function get(id, ctxdata) {
      var entry = getWithFallback.call(this, id);
      if (entry === null) {
        return '';
      }

      return entry.toString(ctxdata) || '';
    };

    this.getEntity = function getEntity(id, ctxdata) {
      var entry = getWithFallback.call(this, id);
      if (entry === null) {
        return null;
      }

      return entry.valueOf(ctxdata);
    };


    // Helpers

    this.getLocale = function getLocale(code) {
      /* jshint -W093 */

      var locales = this.locales;
      if (locales[code]) {
        return locales[code];
      }

      return locales[code] = new Locale(code, this);
    };


    // Getting ready

    function negotiate(available, requested, defaultLocale) {
      if (available.indexOf(requested[0]) === -1 ||
          requested[0] === defaultLocale) {
        return [defaultLocale];
      } else {
        return [requested[0], defaultLocale];
      }
    }

    function freeze(supported) {
      var locale = this.getLocale(supported[0]);
      if (locale.isReady) {
        setReady.call(this, supported);
      } else {
        locale.build(setReady.bind(this, supported));
      }
    }

    function setReady(supported) {
      this.supportedLocales = supported;
      this.isReady = true;
      this._emitter.emit('ready');
    }

    this.requestLocales = function requestLocales() {
      if (this.isLoading && !this.isReady) {
        throw new L10nError('Context not ready');
      }

      this.isLoading = true;
      var requested = Array.prototype.slice.call(arguments);

      var supported = negotiate(requested.concat('en-US'), requested, 'en-US');
      freeze.call(this, supported);
    };


    // Events

    this.addEventListener = function addEventListener(type, listener) {
      this._emitter.addEventListener(type, listener);
    };

    this.removeEventListener = function removeEventListener(type, listener) {
      this._emitter.removeEventListener(type, listener);
    };

    this.ready = function ready(callback) {
      if (this.isReady) {
        setTimeout(callback);
      }
      this.addEventListener('ready', callback);
    };

    this.once = function once(callback) {
      /* jshint -W068 */
      if (this.isReady) {
        setTimeout(callback);
        return;
      }

      var callAndRemove = (function() {
        this.removeEventListener('ready', callAndRemove);
        callback();
      }).bind(this);
      this.addEventListener('ready', callAndRemove);
    };


    // Errors

    function warning(e) {
      this._emitter.emit('warning', e);
      return e;
    }

    function error(e) {
      this._emitter.emit('error', e);
      return e;
    }
  }



  var DEBUG = false;
  var isPretranslated = false;
  var rtlList = ['ar', 'he', 'fa', 'ps', 'qps-plocm', 'ur'];
  var nodeObserver = false;

  var moConfig = {
    attributes: true,
    characterData: false,
    childList: true,
    subtree: true,
    attributeFilter: ['data-l10n-id', 'data-l10n-args']
  };

  // Public API

  navigator.mozL10n = {
    ctx: new Context(),
    get: function get(id, ctxdata) {
      return navigator.mozL10n.ctx.get(id, ctxdata);
    },
    localize: function localize(element, id, args) {
      return localizeElement.call(navigator.mozL10n, element, id, args);
    },
    translate: function () {
      // XXX: Remove after removing obsolete calls. Bugs 992473 and 1020136
    },
    translateFragment: function (fragment) {
      return translateFragment.call(navigator.mozL10n, fragment);
    },
    setAttributes: setL10nAttributes,
    getAttributes: getL10nAttributes,
    ready: function ready(callback) {
      return navigator.mozL10n.ctx.ready(callback);
    },
    once: function once(callback) {
      return navigator.mozL10n.ctx.once(callback);
    },
    get readyState() {
      return navigator.mozL10n.ctx.isReady ? 'complete' : 'loading';
    },
    language: {
      set code(lang) {
        navigator.mozL10n.ctx.requestLocales(lang);
      },
      get code() {
        return navigator.mozL10n.ctx.supportedLocales[0];
      },
      get direction() {
        return getDirection(navigator.mozL10n.ctx.supportedLocales[0]);
      }
    },
    _getInternalAPI: function() {
      return {
        Error: L10nError,
        Context: Context,
        Locale: Locale,
        Entity: Entity,
        getPluralRule: getPluralRule,
        rePlaceables: rePlaceables,
        getTranslatableChildren:  getTranslatableChildren,
        translateDocument: translateDocument,
        loadINI: loadINI,
        fireLocalizedEvent: fireLocalizedEvent,
        parse: parse,
        compile: compile
      };
    }
  };

  navigator.mozL10n.ctx.ready(onReady.bind(navigator.mozL10n));

  if (DEBUG) {
    navigator.mozL10n.ctx.addEventListener('error', console.error);
    navigator.mozL10n.ctx.addEventListener('warning', console.warn);
  }

  function getDirection(lang) {
    return (rtlList.indexOf(lang) >= 0) ? 'rtl' : 'ltr';
  }

  var readyStates = {
    'loading': 0,
    'interactive': 1,
    'complete': 2
  };

  function waitFor(state, callback) {
    state = readyStates[state];
    if (readyStates[document.readyState] >= state) {
      callback();
      return;
    }

    document.addEventListener('readystatechange', function l10n_onrsc() {
      if (readyStates[document.readyState] >= state) {
        document.removeEventListener('readystatechange', l10n_onrsc);
        callback();
      }
    });
  }

  if (window.document) {
    isPretranslated = (document.documentElement.lang === navigator.language);

    // this is a special case for netError bug; see https://bugzil.la/444165
    if (document.documentElement.dataset.noCompleteBug) {
      pretranslate.call(navigator.mozL10n);
      return;
    }


    if (isPretranslated) {
      waitFor('interactive', function() {
        window.setTimeout(initResources.bind(navigator.mozL10n));
      });
    } else {
      if (document.readyState === 'complete') {
        window.setTimeout(initResources.bind(navigator.mozL10n));
      } else {
        waitFor('interactive', pretranslate.bind(navigator.mozL10n));
      }
    }

  }

  function pretranslate() {
    /* jshint -W068 */
    if (inlineLocalization.call(this)) {
      waitFor('interactive', (function() {
        window.setTimeout(initResources.bind(this));
      }).bind(this));
    } else {
      initResources.call(this);
    }
  }

  function inlineLocalization() {
    var script = document.documentElement
                         .querySelector('script[type="application/l10n"]' +
                         '[lang="' + navigator.language + '"]');
    if (!script) {
      return false;
    }

    var locale = this.ctx.getLocale(navigator.language);
    // the inline localization is happenning very early, when the ctx is not
    // yet ready and when the resources haven't been downloaded yet;  add the
    // inlined JSON directly to the current locale
    locale.addAST(JSON.parse(script.innerHTML));
    // localize the visible DOM
    var l10n = {
      ctx: locale,
      language: {
        code: locale.id,
        direction: getDirection(locale.id)
      }
    };
    translateDocument.call(l10n);

    // the visible DOM is now pretranslated
    isPretranslated = true;
    return true;
  }

  function initResources() {
    var resLinks = document.head
                           .querySelectorAll('link[type="application/l10n"]');
    var iniLinks = [];

    for (var i = 0; i < resLinks.length; i++) {
      var link = resLinks[i];
      var url = link.getAttribute('href');
      var type = url.substr(url.lastIndexOf('.') + 1);
      if (type === 'ini') {
        iniLinks.push(url);
      }
      this.ctx.resLinks.push(url);
    }

    var iniLoads = iniLinks.length;
    if (iniLoads === 0) {
      initLocale.call(this);
      return;
    }

    function onIniLoaded(err) {
      if (err) {
        this.ctx._emitter.emit('error', err);
      }
      if (--iniLoads === 0) {
        initLocale.call(this);
      }
    }

    for (i = 0; i < iniLinks.length; i++) {
      loadINI.call(this, iniLinks[i], onIniLoaded.bind(this));
    }
  }

  function initLocale() {
    this.ctx.requestLocales(navigator.language);
    window.addEventListener('languagechange', function l10n_langchange() {
      navigator.mozL10n.language.code = navigator.language;
    });
  }

  function localizeMutations(mutations) {
    var mutation;

    for (var i = 0; i < mutations.length; i++) {
      mutation = mutations[i];
      if (mutation.type === 'childList') {
        var addedNode;

        for (var j = 0; j < mutation.addedNodes.length; j++) {
          addedNode = mutation.addedNodes[j];

          if (addedNode.nodeType !== Node.ELEMENT_NODE) {
            continue;
          }

          if (addedNode.childElementCount) {
            translateFragment.call(this, addedNode);
          } else if (addedNode.hasAttribute('data-l10n-id')) {
            translateElement.call(this, addedNode);
          }
        }
      }

      if (mutation.type === 'attributes') {
        translateElement.call(this, mutation.target);
      }
    }
  }

  function onMutations(mutations, self) {
    self.disconnect();
    localizeMutations.call(this, mutations);
    self.observe(document, moConfig);
  }

  function onReady() {
    if (!isPretranslated) {
      translateDocument.call(this);
    }
    isPretranslated = false;

    if (!nodeObserver) {
      nodeObserver = new MutationObserver(onMutations.bind(this));
      nodeObserver.observe(document, moConfig);
    }

    fireLocalizedEvent.call(this);
  }

  function fireLocalizedEvent() {
    var event = new CustomEvent('localized', {
      'bubbles': false,
      'cancelable': false,
      'detail': {
        'language': this.ctx.supportedLocales[0]
      }
    });
    window.dispatchEvent(event);
  }

  /* jshint -W104 */

  function loadINI(url, callback) {
    var ctx = this.ctx;
    io.load(url, function(err, source) {
      var pos = ctx.resLinks.indexOf(url);

      if (err) {
        // remove the ini link from resLinks
        ctx.resLinks.splice(pos, 1);
        return callback(err);
      }

      if (!source) {
        ctx.resLinks.splice(pos, 1);
        return callback(new Error('Empty file: ' + url));
      }

      var patterns = parseINI(source, url).resources.map(function(x) {
        return x.replace('en-US', '{{locale}}');
      });
      ctx.resLinks.splice.apply(ctx.resLinks, [pos, 1].concat(patterns));
      callback();
    });
  }

  function relativePath(baseUrl, url) {
    if (url[0] === '/') {
      return url;
    }

    var dirs = baseUrl.split('/')
      .slice(0, -1)
      .concat(url.split('/'))
      .filter(function(path) {
        return path !== '.';
      });

    return dirs.join('/');
  }

  var iniPatterns = {
    'section': /^\s*\[(.*)\]\s*$/,
    'import': /^\s*@import\s+url\((.*)\)\s*$/i,
    'entry': /[\r\n]+/
  };

  function parseINI(source, iniPath) {
    var entries = source.split(iniPatterns.entry);
    var locales = ['en-US'];
    var genericSection = true;
    var uris = [];
    var match;

    for (var i = 0; i < entries.length; i++) {
      var line = entries[i];
      // we only care about en-US resources
      if (genericSection && iniPatterns['import'].test(line)) {
        match = iniPatterns['import'].exec(line);
        var uri = relativePath(iniPath, match[1]);
        uris.push(uri);
        continue;
      }

      // but we need the list of all locales in the ini, too
      if (iniPatterns.section.test(line)) {
        genericSection = false;
        match = iniPatterns.section.exec(line);
        locales.push(match[1]);
      }
    }
    return {
      locales: locales,
      resources: uris
    };
  }

  /* jshint -W104 */

  function translateDocument() {
    document.documentElement.lang = this.language.code;
    document.documentElement.dir = this.language.direction;
    translateFragment.call(this, document.documentElement);
  }

  function translateFragment(element) {
    if (element.hasAttribute('data-l10n-id')) {
      translateElement.call(this, element);
    }

    var nodes = getTranslatableChildren(element);
    for (var i = 0; i < nodes.length; i++ ) {
      translateElement.call(this, nodes[i]);
    }
  }

  function setL10nAttributes(element, id, args) {
    element.setAttribute('data-l10n-id', id);
    if (args) {
      element.setAttribute('data-l10n-args', JSON.stringify(args));
    }
  }

  function getL10nAttributes(element) {
    return {
      id: element.getAttribute('data-l10n-id'),
      args: JSON.parse(element.getAttribute('data-l10n-args'))
    };
  }

  function getTranslatableChildren(element) {
    return element ? element.querySelectorAll('*[data-l10n-id]') : [];
  }

  function localizeElement(element, id, args) {
    if (!id) {
      element.removeAttribute('data-l10n-id');
      element.removeAttribute('data-l10n-args');
      setTextContent(element, '');
      return;
    }

    element.setAttribute('data-l10n-id', id);
    if (args && typeof args === 'object') {
      element.setAttribute('data-l10n-args', JSON.stringify(args));
    } else {
      element.removeAttribute('data-l10n-args');
    }
  }

  function translateElement(element) {
    var l10n = getL10nAttributes(element);

    if (!l10n.id) {
      return false;
    }

    var entity = this.ctx.getEntity(l10n.id, l10n.args);

    if (!entity) {
      return false;
    }

    if (typeof entity === 'string') {
      setTextContent(element, entity);
      return true;
    }

    if (entity.value) {
      setTextContent(element, entity.value);
    }

    for (var key in entity.attributes) {
      if (entity.attributes.hasOwnProperty(key)) {
        var attr = entity.attributes[key];
        if (key === 'ariaLabel') {
          element.setAttribute('aria-label', attr);
        } else if (key === 'innerHTML') {
          // XXX: to be removed once bug 994357 lands
          element.innerHTML = attr;
        } else {
          element.setAttribute(key, attr);
        }
      }
    }

    return true;
  }

  function setTextContent(element, text) {
    // standard case: no element children
    if (!element.firstElementChild) {
      element.textContent = text;
      return;
    }

    // this element has element children: replace the content of the first
    // (non-blank) child textNode and clear other child textNodes
    var found = false;
    var reNotBlank = /\S/;
    for (var child = element.firstChild; child; child = child.nextSibling) {
      if (child.nodeType === Node.TEXT_NODE &&
          reNotBlank.test(child.nodeValue)) {
        if (found) {
          child.nodeValue = '';
        } else {
          child.nodeValue = text;
          found = true;
        }
      }
    }
    // if no (non-empty) textNode is found, insert a textNode before the
    // element's first child.
    if (!found) {
      element.insertBefore(document.createTextNode(text), element.firstChild);
    }
  }

})(this);
