// The Antville Project
// http://code.google.com/p/antville
//
// Copyright 2001–2014 by the Workers of Antville.
//
// Licensed 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.

/**
 * @fileOverview Defines global variables and functions.
 */

String.ELLIPSIS = '…';

app.addRepository(app.dir + '/../lib/jdom-1.0.jar');
app.addRepository(app.dir + '/../lib/jsoup-1.18.3.jar');
app.addRepository(app.dir + '/../lib/lesscss-1.7.0.1.1.jar');
app.addRepository(app.dir + '/../lib/rome-1.0.jar');

app.addRepository('modules/core/Global.js');
app.addRepository('modules/core/HopObject.js');
app.addRepository('modules/core/Filters.js');
app.addRepository('modules/core/JSON');
app.addRepository('modules/core/Number.js');

app.addRepository('modules/helma/File');
app.addRepository('modules/helma/Image.js');
app.addRepository('modules/helma/Html.js');
app.addRepository('modules/helma/Http.js');
app.addRepository('modules/helma/Mail.js');
app.addRepository('modules/helma/Zip.js');

app.addRepository('modules/jala/code/Date.js');
app.addRepository('modules/jala/code/HopObject.js');
app.addRepository('modules/jala/code/ListRenderer.js');
app.addRepository('modules/jala/code/Utilities.js');

// Adding i18n message files as repositories
(function() {
   var dir = new helma.File(app.dir, '../i18n');
   for (let fname of dir.list()) {
     fname.endsWith('.js') && app.addRepository(app.dir + '/../i18n/' + fname);
   }
})();

// I18n.js needs to be added *after* the message files or the translations get lost
app.addRepository('modules/jala/code/I18n.js');

// FIXME: Be careful with property names of app.data;
// they inherit all properties from HopObject!
/**
 * Helma’s built-in application-wide in-memory store.
 * @name app.data
 * @namespace
 */
/**
 * Temporary in-memory store of site callbacks.
 * They will be invoked asynchronously by an Admin method.
 * @see Admin.invokeCallbacks
 * @see scheduler
 * @name app.data.callbacks
 */
app.data.callbacks || (app.data.callbacks = []);
/**
 * Temporary in-memory store of LogEntry instances.
 * They will be made persistent asynchronously by an Admin method.
 * @see Admin.commitEntries
 * @see scheduler
 * @name app.data.entries
 * @type Array
 */
app.data.entries || (app.data.entries = []);
/**
 * In-memory registry of Claustra instances.
 * Claustra are defined in the “claustra” dir.
 * @name app.data.claustra
 * @type Array
 */
app.data.claustra || (app.data.claustra = []);
/**
 * In-memory e-mail message queue.
 * They will be sent asynchronously by an Admin method.
 * @see helma.mail.flushQueue
 * @see scheduler
 * @name app.data.mails
 * @type Array
 */
app.data.mails || (app.data.mails = []);
/**
 * In-memory store of remote requests for counting story hits.
 * They will be made persistent asynchronously by an Admin method.
 * @see Admin.commitRequests
 * @see scheduler
 * @name app.data.requests
 * @type Array
 */
app.data.requests || (app.data.requests = {});

/**
 * The helma.File prototype is defined as a module.
 * @name helma.File
 * @namespace
 */
/**
 * Helper method for recursively copying a directory and its files.
 * @param {helma.File} target
 */
helma.File.prototype.copyDirectory = function(target) {
  /*
  // Strange enough, Apache commons is not really faster...
  var source = new java.io.File(this.toString());
  target = new java.io.File(target.toString());
  return Packages.org.apache.commons.io.FileUtils.copyDirectory(source, target);
  */
  this.list().forEach(function(name) {
    var file = new helma.File(this, name);
    if (file.isDirectory()) {
      file.copyDirectory(new helma.File(target, name));
    } else {
      target.makeDirectory();
      file.hardCopy(new helma.File(target, name));
    }
  });
  return;
}

/**
 * The helma.Mail prototype is defined in a module.
 * @name helma.Mail
 * @namespace
 */
/**
 * Add an e-mail message to the mail queue for later sending.
 * @see app.data.mails
 * @returns {Number} The number of mails waiting in the queue
 */
helma.Mail.prototype.queue = function() {
  return app.data.mails.push(this);
}

/**
 * Try to send and remove every mail instance collected in the mail queue.
 * @see app.data.mails
 */
helma.Mail.flushQueue = function() {
  if (app.data.mails.length > 0) {
    app.debug('Flushing mail queue, sending ' +
        app.data.mails.length + ' messages');
    var mail;
    while (app.data.mails.length > 0) {
      mail = app.data.mails.pop();
      mail.send();
      if (mail.status > 0) {
        app.debug('Error while sending e-mail (status ' + mail.status + ')');
        mail.writeToFile(getProperty('smtp.dir'));
      }
    }
  }
  return;
}

/**
 * The jala.i18n namespace is defined in a module.
 * @name jala.i18n
 * @namespace
 */
jala.i18n.setLocaleGetter(function() {
  return (res.handlers.site || root).getLocale();
});

/**
 * The date format used in SQL queries and commands.
 * @constant
 * @type String
 */
var SQLDATEFORMAT = 'yyyy-MM-dd HH:mm:ss';

/**
 * Regular Expression according to Jala’s HopObject.getAccessName().
 * @constant
 * @type RegExp
 */
var NAMEPATTERN = /[\/+\\]/;

/**
 * Shortcut for a function with empty body.
 * Used e.g. in the disableMacro() method.
 * @see disableMacro
 * @function
 */
var idle = new Function;

/**
 * An instance of Helma’s HTML rendering module.
 * @type helma.Html
 */
var html = new helma.Html();

/**
 * An instance of the LESS parser.
 * @type less.Parser
 */
var lessParser = {
  parse: function(lessCode, callback) {
    var less = new Packages.org.lesscss.LessCompiler();
    try {
      callback(null, {
        toCSS: function() {
          return less.compile(lessCode);
        }
      });
    } catch (error) {
      callback(error);
    }
  }
};

/**
 * A collection of Java classes and namespaces required for parsing and generating RSS.
 * @type Object
 */
var rome = new JavaImporter(
  Packages.com.sun.syndication.io,
  Packages.com.sun.syndication.feed.synd
);

var sanitizeHtml = Packages.org.jsoup.Jsoup.clean;

/**
 * A simple and hackish implementation of the console instance of some browsers.
 * @namespace
 */
var console = function(type) {
  /**
   * Convenience method for bridging log output from the server to the client.
   * @methodOf console
   * @param {String} text This text will be displayed in the browser’s console (if available).
   */
  return function(text /*, text, … */) {
    var now = formatDate(new Date, 'yyyy/MM/dd HH:mm:ss');
    var argString = Array.prototype.join.call(arguments, String.SPACE);

    var shellColors = {
      debug: '\u001B[0m',
      error: '\u001B[0;31m',
      fatal: '\u001B[1;31m',
      info: '\u001B[0m',
      log: '\u001B[0m',
      warn: '\u001B[0;33m'
    };

    var output = [
      shellColors[type],
      '[', now, '] ',
      '[', type === 'log' ? 'INFO' : type.toUpperCase(), '] ',
      '[console] ',
      argString,
      '\u001B[0m'
    ];

    writeln(output.join(''));

    if (typeof res !== 'undefined') {
      res.debug('<script>console.' + type + '("%c%s", "font-style: italic;", ' +
          JSON.stringify(argString) + ');</script>');
    }
  }
};

console.log = console('log');
console.debug = console('debug');
console.info = console('info');
console.warn = console('warn');
console.error = console('error');

/**
 * The startup handler Helma is calling automatically shortly after the application has started.
 */
function onStart() {
  if (typeof root === 'undefined') {
    app.logger.error('Error in database configuration: no root site found.');
    return;
  }
  // Load add-ons aka claustra
  Claustra.load();
  // This is necessary once to be sure that aspect-oriented code will be applied
  HopObject.prototype.onCodeUpdate && HopObject.prototype.onCodeUpdate();
  return;
}

/**
 * This handler is called by Helma automatically before the application is stopped.
 */
function onStop() { /* Currently empty, just to avoid annoying log message */ }

/**
 * Helper method to simultaneously define constants and a corresponding array of localized display names.
 * @param {HopObject} ctor The desired prototype constructor the constants should be defined for.
 * @returns {Function}
 */
function defineConstants(ctor /*, arguments */) {
  var constants = [], name;
  for (var i=1; i<arguments.length; i+=1) {
    name = arguments[i].toUpperCase().replace(/\s/g, '');
    ctor[name] = arguments[i].toLowerCase();
    constants.push(arguments[i]);
  }
  return function() {
    return constants.map(function(item) {
      return {
        value: item.toLowerCase(),
        display: gettext(item)
      }
    });
  };
}

/**
 * Disables a macro with the idle() function.
 * @see idle
 * @param {HopObject} ctor The prototype constructor the macro is defined for
 * @param {String} name The macro’s name
 * @returns {Function}
 */
function disableMacro(ctor, name) {
  return ctor.prototype[name + '_macro'] = idle;
}

/**
 * Helma’s built-in main scheduler function.
 * This method is regularly called by Helma after a defined period of time.
 * The period is either defined by the return value or by the schedulerInterval property in app.properties.
 * @returns {Number} The period in milliseconds the scheduler will be called again.
 */
function scheduler() {
  helma.Mail.flushQueue();
  Admin.commitEntries();
  Admin.commitRequests();
  Admin.invokeCallbacks();
  Admin.updateDomains();
  Admin.updateHealth();
  Admin.purgeAccounts();
  Admin.purgeSites();
  return app.properties.schedulerInterval;
};

/**
 * The nightly scheduler.
 * This method is called according to the cron settings in app.properties.
 */
function nightly() {
  var now = new Date;
  if (now - (global.nightly.lastRun || -Infinity) < Date.ONEMINUTE) {
    return; // Avoid running twice when main scheduler runs more than once per minute
  }
  app.log('***** Running nightly scripts *****');
  Admin.purgeReferrers();
  Admin.dequeue();
  global.nightly.lastRun = now;
  return;
}

/**
 * Renders a string depending on the comparison of two values.
 * If the first value equals the second value, the first result will be returned; the second result otherwise.
 * @example <% if <% macro %> is value then <% yes suffix=! %> else 'no :(' %>;
 * Note that any value or result can be a macro, too. Thus, this can be used as
 * a simple implementation of an if-then-else statement by using Helma macros only.
 * @param {Object} param The default Helma macro parameter object
 * @param {String} firstValue The first value
 * @param {String} _is_ Syntactic sugar; should be 'is' for legibility
 * @param {String} secondValue The second value
 * @param {String} _then_ Syntactic sugar; should be 'then' for legibility
 * @param {String} firstResult The first result, ie. the value that will be
 * returned if the first value equals the second one
 * @param {String} _else_ Syntactic sugar; should be 'else' for legibility
 * @param {String} secondResult The second result, ie. the value that will be
 * returned if the first value does not equal the second one
 * @returns {String} The resulting value
 */
function if_macro(param, firstValue, _is_, secondValue, _then_, firstResult,
    _else_, secondResult) {
  return (('' + firstValue) == ('' + secondValue)) ? firstResult : secondResult;
}

/**
 * Renders the current date and time.
 * @see formatDate
 * @param {Object} param The default Helma macro parameter object
 * @param {String} [format] A date format string
 * @returns {String} The formatted current date string
 */
var now_macro = function(param, format) {
  return formatDate(new Date, format || param.format);
};

/**
 * Renders a link.
 * @see renderLink
 * @returns {String} The rendered HTML link element
 */
function link_macro() {
  return renderLink.apply(this, arguments);
}

/**
 *
 * @param {Object} param
 * @param {HopObject} object
 */
function count_macro(param, object) {
  if (object && object.size && object.size instanceof Function) {
    res.write(object.size());
  }
  return;
}

/**
 * Renders a skin from within a skin.
 * @see HopObject#skin_macro
 * @returns {String} The rendered skin
 */
// FIXME: The definition with 'var' is necessary; otherwise the skin_macro()
// method won't be overwritten reliably. (Looks like a Helma bug.)
var skin_macro = function(param, name) {
  return HopObject.prototype.skin_macro.apply(this, arguments);
}

/**
 * Renders a breadcrumbs navigation from the current HopObject path.
 * @param {Object} param The default Helma macro parameter object
 * @param {String} [delimiter=' : '] The string visually separating two navigation items
 */
function breadcrumbs_macro (param, delimiter) {
  delimiter || (delimiter = param.separator || ' : ');
  //html.link({href: res.handlers.site.href()}, res.handlers.site.getTitle());
  var offset = res.handlers.site === root ? 1 : 2;
  for (var item, title, i=offset; i<path.length; i+=1) {
    if (item = path[i]) {
      if (!isNaN(item._id) && item.constructor !== Layout) {
        continue;
      }
      if (i === path.length-1 && req.action === 'main') {
        res.write(item.getTitle());
      } else {
        html.link({href: path[i].href()}, item.getTitle());
      }
      (i < path.length-1) && res.write(delimiter);
    }
  }
  if (req.action !== 'main') {
    res.write(delimiter);
    res.write(gettext(req.action.titleize()));
  }
  return;
}

/**
 * Helper macro for checking if a user session is authenticated (logged in).
 * Returns true if user is logged in, false otherwise.
 * @returns {Boolean}
 */
function user_macro() {
  return !!session.user;
}

function username_macro() {
  res.write(session.user ? session.user.name : '');
}

/**
 * Renders the URL of, a link to or an arbitrary skin of a story.
 * @param {Object} param The default Helma macro parameter object
 * @param {String} [param.skin='embed'] The name of a story skin
 * @param {String} id The id or path of the desired story
 * @param {String} [mode] Either of 'url' or 'link'
 * @example <% story 1810 skin=preview %> Story #1810 in preview skin
 * <% story blog/1971 url %> URL of the story of site “blog”
 */
function story_macro(param, id, mode) {
  var story = HopObject.getFromPath(id, 'stories');
  if (!story || !story.getPermission('main')) {
    return;
  }

  switch (mode) {
    case 'url':
    res.write(story.href());
    break;
    case 'link':
    html.link({href: story.href()}, param.text || story.getTitle());
    break;
    default:
    var skin = param.skin ? 'Story#' + param.skin : '$Story#embed';
    story.renderSkin(skin);
  }
  return;
}

/**
 * Renders the URL or an arbitrary skin of a file.
 * @param {Object} param The default Helma macro parameter object
 * @param {String} [param.skin='main'] The name of a file skin
 * @param {String} id The id or path of the desired file
 * @param {String} [mode] Currently only possible value is 'url'
 * @example <% file 1810 url %> URL of file #1810
 * <% file blog/text.pdf skin=preview %> File in site “blog” using preview skin
 * <% file /image.raw %> Static file of root site
 */
function file_macro(param, id, mode) {
  if (!id) {
    return;
  }

  var file;
  if (id.startsWith('/')) {
    name = id.substring(1);
    if (mode === 'url') {
      res.write(root.getStaticUrl(name));
    } else {
      file = root.getStaticFile(name);
      res.push();
      File.prototype.contentLength_macro.call({
        contentLength: file.getLength()
      });
      res.handlers.file = {
        href: root.getStaticUrl(name),
        name: name,
        contentLength: res.pop()
      };
      File.prototype.renderSkin('File#main');
    }
    return;
  }

  file = HopObject.getFromPath(id, 'files');

  if (!file) {
     if (res.contentType === 'text/html' && !id.contains('/')) {
      res.handlers.site.link_macro({
        'class': 'uk-icon-button uk-icon-file-o',
        'data-uk-tooltip': "{pos: 'right'}",
        title: gettext('Create missing file'),
      }, 'files/create?name=' + encodeURIComponent(id), ' ');
    }
    return;
  }

  if (mode === 'url') {
    res.write(file.getUrl());
  } else {
    if (mode === 'player') {
      param.skin = file.contentType.split('/')[0];
    }
    file.renderSkin('File#' + (param.skin || 'main'));
  }
  return;
}

/**
 * Renders the URL, a thumbnail or an HTML element of an image.
 * @see Image#thumbnail_macro
 * @see Image#render_macro
 * @param {Object} param The default Helma macro parameter object
 * @param {String} id The id or path of the desired image
 * @param {String} [mode] Either of 'url' or 'thumbnail'
 */
function image_macro(param, id, mode) {
  if (!id) {
    return;
  }

  var image;
  if (id.startsWith('/')) {
    var name = id.substring(1);
    image = Images.Default[name] || Images.Default[name + '.gif'];
  } else {
    image = HopObject.getFromPath(id, 'images');
  }

  if (!image && param.fallback) {
    image = HopObject.getFromPath(param.fallback, 'images');
  }

  if (!image) {
     if (res.contentType === 'text/html' && !id.contains('/')) {
      res.handlers.site.link_macro({
        'class': 'uk-icon-button uk-icon-picture-o',
        'data-uk-tooltip': "{pos: 'right'}",
        title: gettext('Create missing image'),
      }, 'images/create?name=' + encodeURIComponent(id), ' ');
    }
    return;
  }

  switch (mode) {
    case 'url':
    res.write(image.getUrl());
    break;
    case 'thumbnail':
    case 'popup':
    var url = param.link || image.getUrl();
    html.openTag('a', {href: url});
    // FIXME: Bloody popups belong to compatibility layer
    if (mode === 'popup') {
      param.onclick = 'javascript: openPopup(\'' + url + '\', ' + image.width + ', ' + image.height + '); return false;';
    }
    image.thumbnail_macro(param);
    html.closeTag('a');
    break;
    case 'box':
    // Default Images do not provide the renderSkin() method
    if (image.renderSkin) {
      if (!param.link) param.link = image.getUrl();
      image.renderSkin(param.skin || '$Image#embed', param);
    }
    break;
    default:
    image.render_macro(param);
  }
  return;
}

/**
 * Renders the URL, a link or the visual representation of a poll.
 * @param {Object} param The default Helma macro parameter object
 * @param {String} id The id or path of the desired poll
 * @param {String} mode Either of 'url' or 'link'
 */
function poll_macro(param, id, mode) {
  if (!id) {
    return;
  }

  var poll = HopObject.getFromPath(id, 'polls');
  if (!poll) {
    return;
  }

  switch (mode) {
    case 'url':
    res.write(poll.href());
    break;
    case 'link':
    html.link({
      href: poll.href(poll.status === Poll.CLOSED ? 'result' : '')
    }, poll.question);
    break;
    default:
    poll.renderSkin('$Poll#embed');
  }
  return;
}

/**
 * The “swiss army knife” list macro. Lists collections of HopObjects.
 * There is hardly a thing it cannot do… but it’s kind of messy, though.
 * @param {Object} param The default Helma macro parameter object
 * @param {String} [param.skin=preview] The name of a skin suitable for the collection
 * @param {String} id The identifier of the desired collection
 * @param {Number} [limit=25] The maximum amount of items listed
 * @example <% list sites %>
 * <% list updates 10 %>
 * <% list blog/comments %>
 * <% list featured skin=promotion %>
 * <% list images %>
 * <% list postings %>
 * <% list stories %>
 * <% list tags %>
 */
function list_macro(param, id, limit) {
  if (!id) {
    return;
  }

  var skin;
  var collection = [];
  var max = Math.min(limit || 25, 100);

  if (id === 'sites') {
    collection = root.sites.list(0, max);
    skin = 'Site#preview';
  } else if (id === 'updates') {
    collection = root.updates.list(0, max).map(function (site) {
      return site.stories.union.get(0);
    });
    skin = 'Story#preview';
  } else {
    var site, type;
    var parts = id.split('/');
    if (parts.length > 1) {
      type = parts[1];
      site = root.sites.get(parts[0]);
    } else {
      type = parts[0];
    }

    site || (site = res.handlers.site);
    var filter = function(item, index) {
      return index < max && item.getPermission('main');
    }

    var commentFilter = function(item) {
      if (item.story.status !== Story.CLOSED &&
          item.site.commentMode !== Site.DISABLED &&
          item.story.commentMode !== Story.CLOSED) {
        return true;
      }
      return false;
    }

    switch (type) {
      case 'comments':
      if (site.commentMode !== Site.DISABLED) {
        var comments = site.stories.comments;
        collection = comments.list().filter(filter);
        skin = 'Story#preview';
      }
      break;

      case 'featured':
      collection = site.stories.featured.list(0, max);
      skin = 'Story#preview';
      break;

      case 'images':
      collection = site.images.list(0, max);
      skin = 'Image#preview';
      break;

      case 'macros':
      res.handlers.site.stories.renderSkin('$Stories#macros');
      return;

      case 'postings':
      var content = site.stories.union;
      collection = content.list().filter(filter).filter(function(item) {
        if (item.constructor === Comment) {
          return commentFilter(item);
        }
        return true;
      });
      skin = 'Story#preview';
      break;

      case 'stories':
      var stories = site.stories.recent;
      var counter = 0;
      collection = stories.list().filter(function(item, index) {
        return item.constructor === Story && filter(item, counter++);
      });
      skin = 'Story#preview';
      break;

      case 'tags':
      return site.tags.list_macro(param, param.skin || '$Tag#preview');
      break;

      default:
      break;
    }
  }
  for (let item of collection) {
    item && item.renderSkin(param.skin || skin);
  }
  return;
}

/**
 * Defines and renders a value.
 * This works like a variable that can be set in one skin and rendered in another –
 * which must be rendered later than the one setting the variable.
 * @param {Object} param The default Helma macro parameter object.
 * @param {String} name The name of the value.
 * @param {String} [value] The desired value.
 * If no value is given, the current value will be rendered.
 * @example <% value foo=bar %> Defines res.meta.values.foo = bar
 * @example <% value foo %> Renders the value of res.meta.value.foo
 */
function value_macro(param, name, value) {
  if (!name) return;
  if (!res.meta.values) res.meta.values = {};
  name = name.toLowerCase();
  if (!value) return res.meta.values[name];
  res.meta.values[name] = value;
  return;
}

function href_macro(param) {
  var href = path.href(req.action === 'main' ? String.EMPTY : req.action);
  if (!href.startsWith('http')) {
    href = req.servletRequest.rootURL + href;
  }
  return res.write(href);
}

/**
 * Renders either a skin or the URL of a random site, story or image.
 * The corresponding story and image collections will be retrieved either from res.handlers.site or
 * from the prefixed “type” argument (e.g. “mySite/story”).
 * Furthermore, both collections can be reduced to a specific tag or gallery, resp.
 * @param {Object} param The default Helma macro parameter object.
 * @param {String} [param.skin = 'preview'] The name of the skin to render in default output mode.
 * @param {String} [param.tag] Reduce the story collection to stories with the specified tag.
 * @param {String} [param.gallery] Reduce the image collection to images from the specified gallery.
 * @param {String} type The type of object to render. Either of “site”, “story” or “image”.
 * It can be prepended by a site name delimited by a slash: “mySite/image”.
 * @param {String} [mode] Set the output mode. Currently, only “url” is supported.
 * @example <% random site skin=preview %> Renders the preview skin of a random site.
 * <% random story tag=essay url %> Renders the URL of a random story tagged with “essay”.
 * <% random foo/image gallery=cat %> Renders the default skin of a random image in the gallery “cat“ of the site “foo”.
 */
function random_macro(param, type, mode) {
  var getRandom = function(n) {
    return Math.floor(Math.random() * n);
  };

  var site = res.handlers.site;

  if (type === 'site') {
    site = root.sites.get(getRandom(root.sites.size()));
    mode === 'url' ? res.write(site.href()) :
        site.renderSkin(param.skin || 'Site#preview');
    return;
  }

  var parts = type.split('/');
  if (parts.length > 1) {
    site = root.sites.get(parts[0] || 'www');
    type = parts[1];
  } else {
    type = parts[0];
  }

  if (!site) {
    return;
  }

  switch (type) {
    case 'story':
    case 'stories':
    var stories = param.tag ? site.stories.tags.get(param.tag) :
        site.stories.featured;
    var story = stories && stories.get(getRandom(stories.size()));
    if (story) {
      param.tag && (story = story.tagged);
      mode === 'url' ? res.write(story.href()) :
          story.renderSkin(param.skin || 'Story#preview');
    }
    break;

    case 'image':
    case 'images':
    var images = param.gallery ? site.images.galleries.get(param.gallery) :
        site.images;
    var image = images && images.get(getRandom(images.size()));
    if (image) {
      param.gallery && (image = image.tagged);
      mode === 'url' ? res.write(image.href()) :
          image.renderSkin(param.skin || 'Image#preview');
    }
    break;
  }
  return;
}

/**
 * Renders the Antville version string.
 * @param {Object} param The default Helma macro parameter object.
 * @param {String} [type = 'default'] The type of version string.
 * @see Root.VERSION
 */
function version_macro(param, type) {
  var version = Root.VERSION;
  var result = version[type || 'default'];
  return result || version;
}

/**
 * A simple Helma macro filter returning one of two possible values depending on which one is truthy.
 * @param {Object} value The original (desired) value.
 * @param {Object} param The default Helma macro parameter object.
 * @param {Object} defaultValue The fallback value for use if the original value should be untruthy.
 * @returns {Object} The value argument if truthy, the defaultValue argument otherwise.
 */
function default_filter(value, param, defaultValue) {
  return value || defaultValue;
}

/**
 * Helma macro filter wrapping the {@link Date#getAge} method.
 * @see Date#getAge
 * @param {Date} value The original date.
 * @param {Object} param The default Helma macro parameter object.
 * @returns {String} The resulting age string of the original date.
 */
function age_filter(value, param) {
  if (!value || value.constructor !== Date) {
    return value;
  }
  return value.getAge();
}

/**
 * Helma macro filter wrapping the {@link Date#getExpiry} method.
 * @see Date#getExpiry
 * @param {Date} value The original date.
 * @param {Object} param The default Helma macro parameter object.
 * @returns {String} The resulting expiry string of the original date.
 */
function expiry_filter(value, param) {
  if (!value || value.constructor !== Date) {
    return value;
  }
  return value.getExpiry();
}

/**
 * Helma macro filter wrapping the {@link renderLink} method.
 * @param {String} text The link text.
 * @param {String} param The default Helma macro parameter object.
 * @param {Object} [url = text] The link URL.
 * @returns {String} The rendered link element
 * @see renderLink
 */
function link_filter(text, param, url) {
  if (text) {
    url || (url = text);
    res.push();
    renderLink(param, url, text);
    return res.pop();
  }
  return;
}

/**
 * Helma macro filter wrapping the global formatting methods.
 * @see formatNumber
 * @see formatDate
 * @param {Object} value The original value.
 * @param {Object} param The default Helma macro parameter object.
 * @param {String} pattern A formatting pattern suitable for the formatting fmethod.
 * @param {String} [type] Deprecated.
 * @returns {String} The formatted string.
 */
function format_filter(value, param, pattern, type) {
  if (!value && value !== 0) {
    return;
  }
  if (type) {
    var Ctor = global[type.titleize()];
    if (Ctor) value = new Ctor(value);
  } else {
    type = value.constructor.name;
  }
  var format = global['format' + type.titleize()];
  if (format && format.constructor === Function) {
    return format(value, pattern || param.pattern);
  }
  return value;
}

/**
 * Macro filter for clipping output.
 * @param {String} input The original input.
 * @param {Object} param The default Helma macro parameter object.
 * @param {Number} [limit = 20] The maximum amount of text parts to be displayed.
 * @param {String} [clipping = '...'] The replacement for the clipped portions of the text.
 * @param {String} [delimiter = '\\s'] The regular expression string used to split the text into parts.
 * @returns {String} The clipped result.
 */
function clip_filter(input, param, limit, clipping, delimiter) {
  var len = 0;
  if (input) {
    len = input.length;
    input = input.stripTags();
  }
  if (!input && param['default'] === null) {
    return ngettext('({0} character)', '({0} characters)', len);
  }
  limit || (limit = 20);
  clipping || (clipping = String.ELLIPSIS);
  delimiter || (delimiter = '\\s');
  return String(input || '').head(limit, clipping, delimiter);
}

function json_filter(value, param) {
  return JSON.stringify(value);
}

function script_filter(value, param) {
  // Remove <script> element and comments (order in brackets is crucial)
  return value.replace(/\s*(?:<!--|--!?>|<\/?script[^>]*>)\s*/gi, String.EMPTY);
}

/**
 * Renders an HTML <a> element from a URL or HopObject.
 * @see helma.Html#link
 * @see HopObject#link_macro
 * @param {Object} param The default Helma macro parameter object.
 * @param {String} [param.title] An optional link title for use in the “title” attribute.
 * @param {String} url A complete or partial URL string. Optional if “handler” is specified.
 * @param {String} [text] An optional link text.
 * @param {HopObject} handler The HopObject used as base URL. Optional if “url” is specified.
 */
function renderLink(param, url, text, handler) {
  url || (url = param.url || String.EMPTY);
  text || (text = param.text || url);
  if (!text || (handler && !handler.href)) {
    return;
  }
  if (url === '.' || url === 'main') {
    url = String.EMPTY;
  }
  delete param.url;
  delete param.text;
  param.title || (param.title = String.EMPTY);
  if (!handler || url.contains(':')) {
    param.href = url;
  } else if (url.contains('/') || url.contains('?') || url.contains('#')) {
    var parts = url.split(/(\/|\?|#)/);
    param.href = handler.href(parts[0]) + parts.splice(1).join(String.EMPTY);
  } else {
    param.href = handler.href(url);
  }
  html.link(param, text);
  return;
}

/**
 * Validates if a string is suitable for e-mail messaging.
 * @see String#isEmail
 * @param {String} str The string to be validated.
 * @returns {String|null} The e-mail string if valid, null otherwise.
 */
function validateEmail(str) {
	if (str) {
    if (str.isEmail()) {
      return str;
    }
  }
  return null;
}

/**
 * Validates if a string is suitable for requesting a URL.
 * @param {String} str The string to be validated.
 * @returns {String|null} The URL string if valid, null otherwise.
 */
function validateUrl(str) {
  if (str) {
    if (str.isUrl()) {
      return str;
    } else if (str.isEmail()) {
      return 'mailto:' + str;
    } else {
      return null;
    }
  }
  return null;
}

/**
 * Surrounds a string by programmer quotes (like 'foo').
 * @param {String} str The original string.
 * @param {String|RegExp} pattern A pattern for conditional quotes; e.g. '\\s' will make the function only add quotes when the original string contains whitespace.
 * @returns {String} The processed string.
 */
function quote(str, pattern) {
  str = String(str);
  if (!pattern || RegExp(pattern).test(str) || /['"]/.test(str)) {
    str = "'" + str.replace(/('|"|\\)/g, '\\$1') + "'";
  }
  return str;
}

/**
 * Formats a number according to a pattern and the site’s locale setting.
 * @param {Number} number The original number.
 * @param {String} pattern The formatting pattern.
 * @returns {String} The formatted number string.
 */
function formatNumber(number, pattern) {
  return Number(number).format(pattern, res.handlers.site.getLocale());
}

/**
 * Formats a date according to a formatting string and the site’s locale and time zone.
 * @param {Date} date The original date.
 * @param {String} [format = 'full'] The formatting string. Either a {@link http://docs.oracle.com/javase/6/docs/api/java/text/SimpleDateFormat.html Java SimpleDateFormat pattern} or of “short”, “medium”, “long”, “full”, “date”, “time”, “iso” or “text”.
 * @returns {String} The formatted date string.
 */
function formatDate(date, format) {
  if (!date) return null;

  var pattern;
  var site = res.handlers.site;
  var locale = site ? site.getLocale() : null;
  var timezone = site ? site.getTimeZone() : null;

  const getExpiry = diff => {
    let text;
    if (diff < Date.ONEMINUTE) {
      text = gettext('soon');
    } else if (diff < Date.ONEHOUR) {
      text = ngettext('in {0} minute', 'in {0} minutes', Math.round(diff / Date.ONEMINUTE));
    } else if (diff < Date.ONEDAY) {
      text = ngettext('in {0} hour', 'in {0} hours', Math.round(diff / Date.ONEHOUR));
    } else if (diff < 2 * Date.ONEDAY) {
      text = gettext('tomorrow');
    } else if (diff < 7 * Date.ONEDAY) {
      text = ngettext('in {0} day', 'in {0} days', Math.round(diff / Date.ONEDAY));
    } else if (diff < 30 * Date.ONEDAY) {
      text = ngettext('in {0} week', 'in {0} weeks', Math.round(diff / 7 / Date.ONEDAY));
    } else if (diff < 365 * Date.ONEDAY) {
      text = ngettext('in {0} month', 'in {0} months', Math.round(diff / 30 / Date.ONEDAY));
    } else {
      text = ngettext('in {0} year', 'in {0} years', Math.round(diff / 365 / Date.ONEDAY));
    }
    return text;
  };

  switch (format) {
    case null:
    case undefined:
    format = 'full'; // Caution! Passing through to next case block!
    case 'short':
    case 'medium':
    case 'long':
    case 'full':
    var type = java.text.DateFormat[format.toUpperCase()];
    pattern = java.text.DateFormat.getDateTimeInstance(type, type, locale).toPattern();
    break;

    case 'date':
    var type = java.text.DateFormat.FULL
    pattern = java.text.DateFormat.getDateInstance(type, locale).toPattern();
    break;

    case 'time':
    var type = java.text.DateFormat.SHORT;
    pattern = java.text.DateFormat.getTimeInstance(type, locale).toPattern();
    break;

    case 'iso':
    pattern = Date.ISOFORMAT;
    break;

    case 'text':
    var text;
    var now = new Date;
    var diff = now - date;

    if (diff < 0) {
      text = getExpiry(-diff);
    } else if (diff < Date.ONEMINUTE) {
      text = gettext('right now');
    } else if (diff < Date.ONEHOUR) {
      text = ngettext('{0} minute ago', '{0} minutes ago', Math.round(diff / Date.ONEMINUTE));
    } else if (diff < Date.ONEDAY) {
      text = ngettext('{0} hour ago', '{0} hours ago', Math.round(diff / Date.ONEHOUR));
    } else if (diff < 2 * Date.ONEDAY) {
      text = gettext('yesterday');
    } else if (diff < 7 * Date.ONEDAY) {
      text = ngettext('{0} day ago', '{0} days ago', Math.round(diff / Date.ONEDAY));
    } else if (diff < 30 * Date.ONEDAY) {
      text = ngettext('{0} week ago', '{0} weeks ago', Math.round(diff / 7 / Date.ONEDAY));
    } else if (diff < 365 * Date.ONEDAY) {
      text = ngettext('{0} month ago', '{0} months ago', Math.round(diff / 30 / Date.ONEDAY));
    } else {
      text = ngettext('{0} year ago', '{0} years ago', Math.round(diff / 365 / Date.ONEDAY));
    }
    return text.replace(/(\d+)\s+/, '$1\xa0'); // Add a no-break space after first digits

    case 'expiry':
    return getExpiry(date - new Date());

    default:
    pattern = format;
  }

  try {
    return date.format(pattern, locale, timezone);
  } catch (ex) {
    return '[Invalid date format]';
  }

  return String.EMPTY;
}

/**
 * Injects the XSLT stylesheet declaration into an XML string until Mozilla developers will have mercy.
 * FIXME: Obsolete?
 * @param {String} xml An XML string
 * @returns {String} An XML string containing the XSLT stylesheet declaration
 */
function injectXslDeclaration(xml) {
  res.push();
  renderSkin('Global#xslDeclaration');
  return xml.replace(/(\?>\r?\n?)/, '$1' + res.pop());
}

/**
 * General mail sending function. Mails will be queued in app.data.mails.
 * @param {Object} recipient The recipient's email addresses
 * @param {String} subject The e-mail's subject
 * @param {String} body The body text of the e-mail
 * @returns {Number} The status code of the underlying helma.Mail instance
 */
function sendMail(recipient, subject, body, options) {
  options || (options = {});
  if (!recipient || !body) {
    throw Error('Insufficient arguments in method sendMail()');
  }
  var mail = new helma.Mail(getProperty('smtp', 'localhost'),
      getProperty('smtp.port', '25'));
  mail.setFrom(root.replyTo || 'root@localhost');
  if (recipient instanceof Array) {
    for (var i in recipient) {
      mail.addBCC(recipient[i]);
    }
  } else {
    mail.addTo(recipient);
  }
  mail.setSubject(subject);
  mail.setText(body);
  if (options.footer !== false) { // It is the exception to have no footer
    mail.addText(renderSkinAsString('$Global#mailFooter'));
  }
  mail.queue();
  return mail.status;
}

/**
 * Retrieves the locale object from a language string.
 * @param {String} language The name of the language.
 * @returns {java.util.Locale} The corresponding locale object.
 */
function getLocale(language) {
  return new java.util.Locale(language || 'english');
}

/**
 * Creates an array of all available Java locales sorted by their names.
 * @param {String} language The optional language of the locales
 * @returns {Object[]} A sorted array containing the corresponding locales
 */
function getLocales(language) {
  var result = [], locale, localeString;
  var locales = java.util.Locale.getAvailableLocales();
  for (var i in locales) {
    locale = locales[i];
    localeString = locale.toString();
    if (localeString && !localeString.contains('_')) {
      var isTranslated = jala.i18n.getCatalog(jala.i18n.getLocale(localeString));
      result.push({
        value: localeString,
        display: (isTranslated ? String.EMPTY : '⚠︎ ') + locale.getDisplayName(locale),
        'class': isTranslated ? String.EMPTY : 'av-locale-needs-translation'
      });
    }
  }
  // TODO: Automatically integrate gendered german language
  /*
  var builder = java.util.Locale.Builder();
  var locale1 = new java.util.Locale('de__#x-male');
  var locale2 = builder.setLanguage('de')
    .setExtension('x', 'male')
    .build();
  var locale3 = java.util.Locale.forLanguageTag(this.locale);
  var locale = locale3;
  console.log(java.lang.System.getProperty('java.version'));
  console.log(locale, locale.language, locale.toLanguageTag());
  */
  result.push({
    value: 'de-x-male',
    display: 'Deutsch ♂'
  });
  result.sort(new String.Sorter('display'));
  return result;
};

/**
 * This method returns an array of structs providing two properties each:
 * <code>value</code> – a unique time zone ID
 * <code>display</code> – a (more) user-friendly string
 * Although Java is great in providing all time zones one can imagine, this
 * vast amount of choices fails to support easy time zone selection.
 * Furthermore, the L10n features of the java.util.TimeZone class are insufficient
 * as they do only translate the generic string returned by the getDisplayName()
 * method (e.g. Central European Time), not the more usable time zone IDs
 * (e.g. Europe/Vienna). Thus, time zone selection in Antville is rather limited.
 * @param {String} language
 * @returns {Object[]} A sorted array containing the corresponding timezones
 */
function getTimeZones(language) {
  var result = [],
      timeZones = [],
      locale = getLocale(language),
      ids = java.util.TimeZone.getAvailableIDs();

  for (let id of ids) {
    // Exclude confusing time zones
    if (id.length < 4 || !id.contains('/') ||
        id.startsWith('Etc') || id.startsWith('System')) {
      continue;
    }
    let timeZone = java.util.TimeZone.getTimeZone(id);
    // Exclude more confusing time zones
    if (timeZone.getDisplayName().startsWith('GMT')) {
      continue;
    }
    result.push({
      value: timeZone.getID(),
      display: timeZone.getID().replace(/_/g, String.SPACE)
    })
    timeZones.push(timeZone);
  }

  return result.sort(new String.Sorter('display'));
}

// FIXME:
/**
 * Replaces <img> elements in a string with <a> elements to fix RSS output which is not capable of displaying images.
 * @param {String} rss The original RSS output.
 * @returns {String} The transformed RSS output.
 */
function fixRssText(rss) {
  var re = new RegExp('<img src\\s*=\\s*\'?([^\\s\']+)?\'?[^>]*?(alt\\s*=\\s*\'?([^\']+)?\'?[^>]*?)?>', 'gi');
  rss = rss.replace(re, '[<a href=\'$1\' title=\'$3\'>Image</a>]');
  return rss;
}

// FIXME:
/**
 * @ignore
 * @param {Object} src
 */
function doWikiStuff (src) {
  // robert, disabled: didn't get the reason for this:
  // var src= ' '+src;
  if (src == null || !src.contains('<*'))
    return src;

  // do the Wiki link thing, <*asterisk style*>
  var regex = new RegExp ('<[*]([^*]+)[*]>');
  regex.ignoreCase=true;

  var text = '';
  var start = 0;
  while (true) {
    var found = regex.exec (src.substring(start));
    var to = found == null ? src.length : start + found.index;
    text += src.substring(start, to);
    if (found == null)
      break;
    var name = ''+(new java.lang.String (found[1])).trim();
    var item = res.handlers.site.topics.get (name);
    if (item == null && name.lastIndexOf('s') == name.length-1)
      item = res.handlers.site.topics.get (name.substring(0, name.length-1));
    if (item == null || !item.size())
      text += format(name)+' <small>[<a href=\''+res.handlers.site.stories.href('create')+'?topic='+escape(name)+'\'>define '+format(name)+'</a>]</small>';
    else
      text += '<a href=\''+item.href()+'\'>'+name+'</a>';
    start += found.index + found[1].length+4;
  }
  return text;
}

// FIXME: Rewrite with jala.ListRenderer?
/**
 * Renders an HTML list from a HopObject collection or an array.
 * @param {HopObject|Array} collection The original collection of objects.
 * @param {Function|Skin} funcOrSkin A skin name or a rendering function.
 * @param {Number} itemsPerPage The amount of rendered items per page.
 * @param {Number} pageIdx The current page index.
 * @returns {String} The rendered list.
 */
function renderList(collection, funcOrSkin, itemsPerPage, pageIdx) {
  var currIdx = 0, item;
  var isArray = collection instanceof Array;
  var stop = size = isArray ? collection.length : collection.size();

  if (itemsPerPage) {
    var totalPages = Math.ceil(size/itemsPerPage);
    if (isNaN(pageIdx) || pageIdx > totalPages || pageIdx < 0) {
      pageIdx = 0;
    }
    currIdx = pageIdx * itemsPerPage;
    stop = Math.min(currIdx + itemsPerPage, size);
  }

  var isFunction = (funcOrSkin instanceof Function) ? true : false;
  res.push();
  while (currIdx < stop) {
    item = isArray ? collection[currIdx] : collection.get(currIdx);
    isFunction ? funcOrSkin(item) : item.renderSkin(funcOrSkin);
    currIdx += 1;
  }
  return res.pop();
}

// FIXME: Rewrite using jala.ListRenderer or rename (eg. renderIndex)
/**
 * Renders the page navigation for a collection of HopObjects.
 * @param {HopObject|Array|Number} collectionOrSize A collection or just the size of a collection.
 * @param {String} url The base URL for rendering links.
 * @param {Number} itemsPerPage The amount of rendered items per page.
 * @param {Number} pageIdx The current page index.
 * @returns {String} The rendered page navigation.
 */
function renderPager(collectionOrSize, url, itemsPerPage, pageIdx) {
  // Render a single item for the navigation bar
  var renderItem = function(text, cssClass, url, page) {
    var param = {'class': cssClass};
    param.text = text;
    if (url) {
      url += url.contains('?') ? '&' : '?';
      param.href = url + 'page=' + page;
    }
    renderSkin('$Global#pagerItem', param);
    return;
  }

  var maxItems = 5;
  var size = 0;
  if (collectionOrSize instanceof Array) {
    size = collectionOrSize.length;
  } else if (collectionOrSize instanceof HopObject) {
    size = collectionOrSize.size();
  } else if (!isNaN(collectionOrSize)) {
    size = parseInt(collectionOrSize, 10);
  }
  var lastPageIdx = Math.ceil(size/itemsPerPage)-1;
  // If there's just one page no navigation will be rendered
  if (lastPageIdx <= 0) {
    return null;
  }

  // Initialize the parameter object
  var param = {};
  var pageIdx = parseInt(pageIdx, 10);
  // Check if the passed index is correct
  if (isNaN(pageIdx) || pageIdx > lastPageIdx || pageIdx < 0) {
    pageIdx = 0;
  }
  param.display = ((pageIdx * itemsPerPage) + 1) + '-' +
      (Math.min((pageIdx * itemsPerPage) + itemsPerPage, size));
  param.total = size;

  // Render the navigation-bar
  res.push();
  if (pageIdx < 1) {
    param.prevClass = 'uk-disabled';
  } else {
    param.prevHref = '?page=' + (pageIdx - 1);
  }
  var offset = Math.floor(pageIdx / maxItems) * maxItems;
  (offset > 0) && renderItem(String.ELLIPSIS, 'pageNavItem', url, offset-1);
  var currPage = offset;
  var stop = Math.min(currPage + maxItems, lastPageIdx+1);
  while (currPage < stop) {
    if (currPage === pageIdx) {
      renderItem(currPage + 1, 'uk-active');
    } else {
      renderItem(currPage + 1, 'pageNavItem', url, currPage);
    }
    currPage += 1;
  }
  if (currPage < lastPageIdx) {
    renderItem(String.ELLIPSIS, 'pageNavItem', url, offset + maxItems);
  }
  if (pageIdx >= lastPageIdx) {
    param.nextClass = 'uk-disabled';
  } else {
    param.nextHref = '?page=' + (pageIdx + 1);
  }
  param.pager = res.pop();
  return renderSkinAsString('$Global#pager', param);
}

/**
 * Transforms an english plural form of a noun into its singular form.
 * @param {String} plural The noun in plural form.
 * @returns {String} The english singular form of the original input.
 */
function singularize(plural) {
  if (plural.endsWith('ies')) {
    return plural.substring(0, plural.length-3) + 'y';
  }
  return plural.substring(0, plural.length-1);
}

/**
 * Transforms an english singular form of a noun into its plural form.
 * @param {String} singular The noun in singular form.
 * @returns {String} The english plural form of the original input.
 */
function pluralize(singular) {
  if (singular.endsWith('y')) {
    return singular.substring(0, singular.length-1) + 'ies';
  }
  return singular + 's';
}

/**
 * Halts the execution of the thread for the specified amount of milliseconds.
 * Use only for debugging.
 * @param {Number} millis The amount of milliseconds.
 */
var wait = function(millis) {
  millis || (millis = Date.ONESECOND);
  var now = new Date;
  while (new Date - now < millis) {
    void null;
  }
  return;
}

function getLinkCount(item) {
  var content;
  switch (item.constructor) {
    case Comment:
    case Story:
    content = item.title || '' + item.text || '';
    break;

    default:
    content = String(item);
  }
  return (content.match(/https?:\/\//g) || []).length;
}

function getHrefScheme() {
  return getProperty('hrefScheme', 'http') + '://';
}
