/* Ordo.js
   David Chiang dwchiang@yahoo.com
 */

/* To do:

- local modifications: change XML loader to use a queue

- precedence and transfer rules
  * vigils disappear when their feasts are transferred or commemorated
  * still not handling special cases: annunciation, all-souls (1962); solemnities on Sunday (1970)

- multiple feast nodes with matching id merge into one
- should fields append or replace? erasure mechanism?
- select templates by template id rather than rank?
- change rank into an attribute?
- change date format to declarative style instead of sequential
- change occurrence rules to general conditionals?

- multiple liturgies on one day
  * allow liturgy to start up to 24 hours before start of day

- Mass readings and propers
- Office...?

Bugs:
- should seasons be guaranteed to nest?

*/

widget = 1;

// Adapted from http://www.phys.uu.nl/~vgent/easter/easter_text2a.htm
function gregorianPaschalMonth(year, day) 
{
  var ic=Math.floor(year/100);
  var gn=(year%19)+1;
  var ep=(((11*(gn-1)+8-ic+Math.floor(ic/4)+Math.floor((8*ic+13)/25))%30)+30)%30;
  var fmg;
  if(ep <= 23) fmg=136-ep;
  if((ep == 24) || (ep == 25)) fmg=141;
  if((ep == 25) && (gn > 11)) fmg=140;
  if(ep >= 26) fmg=166-ep;
  fmmg=Math.floor(fmg/31);
  fmdg=(fmg%31)+1;
  return YMD_to_CMJD(year, fmmg, fmdg)-14+day;
}

// Need a Julian Paschal month function too

Date.prototype.to_CMJD = function () {
  with (this) return YMD_to_CMJD(getFullYear(),getMonth()+1,getDate());
}

// Adapted from http://www.merlyn.demon.co.uk
function YMD_to_CMJD(y, m, d) { // Fast.  y += 7e8, result -= ...
  if (m<3) { m += 12 ; y-- } // Initial m *must* be in 1..12
  return -678973 + d + (((153*m-2)/5)|0) + // 153 = 13 + 5*28
    (1461*y>>2) - ((y/100)|0) + ((y/400)|0) ;
}

function CMJD_to_YMD(CMJD) {
  var Y=0, M=0, t, d = CMJD + 678881; // 0000-03-01
  t = (( (4*(d+36525))/146097 )|0) - 1; // Gregorian Added Rules
  Y += 100*t ; d -= 36524*t + (t>>2);
  t = (( (4*(d+366))/1461 )|0) - 1; // Julian Rules
  Y += t ; d -= 365*t + (t>>2);
  M = ( (5*d+2)/153 )|0; // 153=5*28+13 months from March, M = 0...
  d -= ((2+M*153)/5)|0; // remove full months, d = 0...
  if (M > 9) { M -= 12 ; Y++ }
  ++d;
  return [Y, M+3, d];
}

function CMJD_to_Date(CMJD) {
  var ymd = CMJD_to_YMD(CMJD);
  var D = new Date(0);
  D.setFullYear(ymd[0], ymd[1]-1, ymd[2]);
  return D;
}

function CMJD_getDOW(d) {
  return (d+3)%7;
}

// returns the next (dow) on or after d
function CMJD_nextDOW(dow, d) {
  var ddow = CMJD_getDOW(d);
  if (dow < ddow) {
    dow += 7;
  }
  return d+dow-ddow;
}

var downames=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
var monthnums={"jan":1, "feb":2, "mar":3, "apr":4,
	       "may":5, "jun":6, "jul":7, "aug":8,
	       "sep":9, "oct":10, "nov":11, "dec":12};
var monthnames=["January", "February", "March", "April",
	       "May", "June", "July", "August",
	       "September", "October", "November", "December"];

//// From XMLHttpRequest example from apple.com

// global request and XML document objects
var req;

// retrieve XML document (reusable generic function);
// parameter is URL string (relative or complete) to
// an .xml file whose Content-Type is a valid XML
// type, such as text/xml; XML source must be from
// same domain as HTML file
function loadXMLDoc(url, processReqChange) {
    // branch for native XMLHttpRequest object
    if (window.XMLHttpRequest) {
        req = new XMLHttpRequest();
        req.open("GET", url, true);
        req.onreadystatechange = processReqChange;
        req.overrideMimeType('text/xml');
        req.send(null);
    // branch for IE/Windows ActiveX version
    } else if (window.ActiveXObject) {
        isIE = true;
        req = new ActiveXObject("Microsoft.XMLHTTP");
        if (req) {
            req.onreadystatechange = processReqChange;
            req.open("GET", url, true);
            req.overrideMimeType('text/xml');
            req.send();
        }
    } else {
      error("no XMLHttpRequest support");
    }
}

function loaded() {
  // only if req shows "loaded"
  if (req.readyState == 4) {
    // only if "OK"
    if (1 || req.status == 200) { // when url is a local file, req.status is undefined.
      return 1;
    } else {
      error("There was a problem retrieving the XML data:\n" +
	    req.status+req.statusText);
    }
  }
  return 0;
}

// Given a date node and a window, calculate the possible CMJDs
function computeDate(datenode, start, stop) {
  mapAttributes(datenode, ['month', 'day', 'dow']);
  var ds=new Array();
  var startymd = CMJD_to_YMD(start);
  var stopymd = CMJD_to_YMD(stop);

  if (datenode.month && datenode.day) {
    var day = parseDec(datenode.day);
    for (var year=startymd[0]-1; year<=stopymd[0]; year++) { // overkill
      if (datenode.month == "gpm") {
	ds.push(gregorianPaschalMonth(year, day));
      } else if (datenode.month == "febb") { // special Feb which is bissextile in leap years
	var d = YMD_to_CMJD(year, 2, day);
	// in leap years, ecclesiastical Feb 24-28 are civil Feb 25-29
	// apparently, civil Feb 24 is nothing
	if (year%4 == 0 && (year%100 != 0 || year%400 == 0) && day >= 24)
	  d++;
	ds.push(d);
      } else {
	ds.push(YMD_to_CMJD(year, monthnums[datenode.month], parseDec(datenode.day)));
      }
    }
  }

  if (datenode.dow) {
    if (ds.length > 0) {
	// slide to next specified day of week (if any)
	for (var i=0; i<ds.length; i++) 
	  ds[i] = CMJD_nextDOW(parseDec(datenode.dow), ds[i]); 
    } else {
      var dows = parseNum(datenode.dow);
      for (var i=0; i<dows.length; i++) {
	var d = CMJD_nextDOW(dows[i], start);
	while (d<stop) {
	  ds.push(d);
	  d += 7;
	}
      }
    }
  }
  var newds = new Array();
  for (var i=0; i<ds.length; i++)
    if (ds[i] >= start && ds[i] < stop && 
	(newds.length == 0 || ds[i] != newds[newds.length-1]))
    newds.push(ds[i]);
  return newds;
}

function formatName(s, d, week) {
  s = s.replace(/%f/, downames[CMJD_getDOW(d)]);
  var ymd = CMJD_to_YMD(d);
  s = s.replace(/%m/, monthnames[ymd[1]-1]);
  s = s.replace(/%d/, String(ymd[2]));
  if (week != undefined) {
    s = s.replace(/%w/, String(week));
    s = s.replace(/%W/, ordinal(week));
  }
  return s;
}

var ordinal_suffixes = ['th','st','nd','rd','th','th','th','th','th','th'];
function ordinal(n) {
  if (n >= 11 && n <= 19)
    return n + 'th';
  else
    return n + ordinal_suffixes[n%10];
}

function parseDec(s) {
  return parseInt(s, 10);
}

// Parse number specifications like "1-2,4-7,9" => [1,2,4,5,6,7,9]
function parseNum(s) {
  var result = new Array();
  var ranges = s.split(/,/);
  for (var i=0; i<ranges.length; i++) {
    var nums = ranges[i].split(/-/);
    if (nums.length==1)
      result.push(parseDec(nums[0]));
    else if (nums.length==2)
      for (var j=parseDec(nums[0]); j<=parseDec(nums[1]); j++)
	result.push(j);
    else
      error("Invalid numeric range: " + ranges[i]);
  }
  return result;
}

// startx, stopx: flags indicating that start or stop date is exclusive
// start, stop: scope of search. does not clip results

// bug: doesn't catch the case where the start/stop window is completely contained within a season
function computeDateRange(startdatenode, stopdatenode, startx, stopx, start, stop) {
  var buffer = 366; // extra days to search for overflowing season
  var startds = computeDate(startdatenode, start, stop);
  var stopds = computeDate(stopdatenode, start, stop);
  var tmp;

  if (startx)
    for (var i=0; i<startds.length; i++)
      startds[i]--;
  if (!stopx)
    for (var i=0; i<stopds.length; i++)
      stopds[i]++;

  if (startds.length == 0 && stopds.length == 0)
    return [];
  if (startds.length == 0 || startds[0] > stopds[0]) {
    tmp = computeDate(startdatenode, start-buffer, start);
    startds.unshift(tmp[tmp.length-1]);
  }
  if (stopds.length == 0 || startds[startds.length-1] > stopds[stopds.length-1]) {
    tmp = computeDate(stopdatenode, stop, stop+buffer);
    stopds.push(tmp[0]);
  }
  if (startds.length != stopds.length) {
    error("Problem computing date ranges, length mismatch, " + startds.length + " != " + stopds.length + ", id = " + startdatenode.parentNode.id);
  } else {
    tmp = new Array(startds.length);
    for (i=0; i<tmp.length; i++) {
      tmp[i] = [startds[i], stopds[i]];
    }
    return tmp;
  }
}

var index;
var calendar;
var start = null, stop = null;
var cur = (new Date()).to_CMJD();

// pstart/pstop has two purposes: (1) constrain dates of contents; (2)
// provide origin for relative dates of contents.
function seasonHelper(season, pstart, pstop) {
  var ranges = [[pstart, pstop]]; // default is to pass date range down to children
  if (season.tagName == "season") {
    mapAttributes(season, ["id"]);
    index[season.id] = season;
    var startnode, startx, endnode, endx;
    var datenodes = season.getChildrenByTagName("date");
    for (var j=0; j<datenodes.length; j++) {
      mapAttributes(datenodes[j], ["type"]);
      if (datenodes[j].type == "start") {
	startnode = datenodes[j];
	startx = 0;
      } else if (datenodes[j].type == "end") {
	endnode = datenodes[j];
	endx = 0;
      } else if (datenodes[j].type == "endx") {
	endnode = datenodes[j];
	endx = 1;
      } else
	error("season dates must have type start, end, or endx, id = " + season.id + ", type = " + datenodes[j].type);
    }
    if (!startnode || !endnode) {
      error("bad date range for season " + season.id);
    } else {
      ranges = computeDateRange(startnode, endnode, startx, endx, start, stop);
      /*
	// what was this?
        for (var j=0; j<ranges.length; j++) {
	if ((pstart != null && ranges[j][0] < pstart) || 
	    (pstop != null && ranges[j][1] > pstop))
	  continue;
      }*/
    }
  }
  
  // Now process child seasons
  var children;
  for (var j=0; j<ranges.length; j++) {
    children = season.getChildrenByTagName("season");
    for (var i=0; i<children.length; i++)
      seasonHelper(children[i], ranges[j][0], ranges[j][1]);
  }
    
  // Now process child feasts
  for (var j=0; j<ranges.length; j++) {
    children = season.getChildrenByTagName("feast");
    for (var i=0; i<children.length; i++)
      feastHelper(children[i], ranges[j][0], ranges[j][1]);
  }
}

function calculateWeek(season, sstart, sstop, d) {
  var datenodes = season.getChildrenByTagName("date");
  for (var j=0; j<datenodes.length; j++) {
    mapAttributes(datenodes[j], ["week"]);
    if (datenodes[j].week) {
      if (datenodes[j].type == "start") {
	return parseDec(datenodes[j].week)+Math.floor((d-CMJD_nextDOW(0, sstart-6))/7);
      } else if (datenodes[j].type == "end") {
	return parseDec(datenodes[j].week)+Math.floor((d-CMJD_nextDOW(0, sstop-6))/7);
      } else if (datenodes[j].type == "endx") {
	return parseDec(datenodes[j].week)+Math.floor((d-CMJD_nextDOW(0, sstop+1-6))/7);
      }
    }
  }
  // Default behavior: start is week 1
  return 1+Math.floor((d-CMJD_nextDOW(0, sstart-6))/7);
}

function feastHelper(feast, pstart, pstop) {
  var flag;
  mapAttributes(feast, ["id"]);
  index[feast.id] = feast;
  var rank = getField(feast, "rank", null);

  dates = getChildren(feast, "date", null);
  for (var i=0; i<dates.length; i++) {
    flag = 0;
    ds = computeDate(dates[i], start, stop);
    for (var j=0; j<ds.length; j++) {
      if ((pstart == null || ds[j]>=pstart) && (pstop == null || ds[j]<pstop)) {
	calendar[ds[j]-start].push([rank, feast.id, pstart, pstop])
	flag = 1;
      }
    }
    if (flag)
      break; // if no dates were entered, try the next date node
  }
}

function templateHelper(tnode) {
  mapAttributes(tnode, ["rank"]);
  var ranks = tnode.rank.split(/,/);
  for (var i=0; i<ranks.length; i++)
    index[ranks[i]] = tnode;
}

// expects global vars start and stop to be set
function computeCalendar(root) {
  index = new Object();

  var season;

  // For now, nesting a template inside a season doesn't do anything
  var templatenodes = root.getElementsByTagName('template');
  for (var i=0; i<templatenodes.length; i++)
    templateHelper(templatenodes[i]);

  calendar = new Array(stop-start);
  for (var i=0; i<calendar.length; i++) {
    calendar[i] = new Array();
  }
  var calnodes = root.getElementsByTagName('calendar');
  if (calnodes.length != 1)
    error("must have exactly one calendar");

  // First pass: traverse input tree and give every feast its 
  // normal day
  seasonHelper(calnodes[0], null, null);

  // Second pass: process any transfer rules
  for (var i=0; i<calendar.length; i++) {
    // Sort calendar entries by rank
    calendar[i].sort(function (x, y) {
      if (x[0] > y[0])
	return 1;
      else if (x[0] < y[0])
	return -1;
      else
	return 0;
    });
    var toprank = null;
    if (calendar[i].length > 0)
      toprank = calendar[i][0][0];
    for (var j=0; j<calendar[i].length; j++) {
      var node = index[calendar[i][j][1]];
      var rank = calendar[i][j][0];
      var orank = toprank < rank ? toprank : null;
      var transfer = getChild(node, "transfer", orank);
      if (transfer != null) {
	mapAttributes(transfer, ["rank"]);
	if (transfer.rank != undefined) {
	  for (var k=i; k<calendar.length; k++) {
	    if (calendar[k].length == 0 || calendar[k][0][0] >= transfer.rank) {
	      calendar[k].unshift(calendar[i].remove(j));
	      break;
	    }
	  }
	} else {
	  error('unknown transfer rule');
	}
      }
    }
  }
}

function displayDay(d) {
  if (start == null || stop == null || d < start || d >= stop) {
    // NB this window must be longer than the longest season
    start = d-180;
    stop = d+180;
    computeCalendar(xmlroot);
  }

  clearDisplay();
  display("<span id='title'>"+CMJD_to_Date(d).toLocaleDateString()+"</span><hr>");
  var offset = d-start;

  var toprank = null;
  if (calendar[offset].length > 0)
    toprank = calendar[offset][0][0];

  var names = new Array();
  for (var i=0; i<calendar[offset].length; i++) {
    var node = index[calendar[offset][i][1]];
    var rank = calendar[offset][i][0];
    var orank = toprank < rank ? toprank : null;
    var pstart = calendar[offset][i][2], pstop = calendar[offset][i][3];
    var name = getField(node, "name", orank);
    if (name != null) {
      var week;
      if (node.parentNode.tagName == "season")
	week = calculateWeek(node.parentNode, pstart, pstop, d);
      name = formatName(name, d, week);

      var notes = getFields(node, "note", orank).join(", ");
      if (notes)
      	notes = " (" + notes + ")";
      if (rank != null) {
	names.push([rank, /*"["+rank+"] "+*/name+notes]);
      }
    } 
  }
  var flag = true;
  for (var i=0; i<names.length; i++) {
    if (flag && i>0 && names[i][0] > names[i-1][0]) {
      display("<center>*</center>");
      flag = false;
    }
    display("<p>"+names[i][1]+"</p>");
  }

  if (widget) {
    currentContentTop = 0; // doesn't work
    if (document.getElementById("front").style.display!="none")
      calculateAndShowThumb(document.getElementById('main')); // deferred to hideBack()
  }
}

function showToday() {
  cur = (new Date()).to_CMJD();
  if (loaded())
    displayDay(cur);
}

function showRelative(k) {
  cur += k;
  if (loaded())
    displayDay(cur);
}

if (window.widget) {
  widget.onshow = function() {
    today = (new Date()).to_CMJD();
    if (cur != today)
    showToday();
  }
  widget.onhide = function() {
  }
}

xmlfiles = [];
xmlroot = null;

/* this doesn't seem to be working quite right yet */
function processReqChange_queue() {
  if (loaded()) {
    if (xmlroot == null)
      xmlroot = req.responseXML;
    else {
      /* merge into a single calendar node */
      var calnodes1 = xmlroot.getElementsByTagName('calendar');
      var calnodes2 = req.responseXML.getElementsByTagName('calendar');
      if (calnodes1.length != 1 || calnodes2.length != 1)
        error('must have exactly one calendar per file');
      for (var i=0; i<calnodes2[0].childNodes.length; i++) {
        var child = calnodes2[0].childNodes[i];
        calnodes2[0].removeChild(child);
        calnodes1[0].appendChild(child);
      }
    }
    loadXMLDocs();
  }
}

function loadXMLDocs() {
  if (xmlfiles.length > 0) 
    loadXMLDoc(xmlfiles.shift(), processReqChange_queue);
  else
    showToday();
}

function changeCalendar(calnames) {
  xmlroot = null;
  start = stop = null;
  xmlfiles = calnames.split(/,/);
  loadXMLDocs();
}

function clearDisplay() {
  var div = document.getElementById('main');
  div.innerHTML="";
}

function display(s) {
  var div = document.getElementById('main');
  div.innerHTML += s;
}

function error(s) {
  var div = document.getElementById('debugDiv');
  if (window.widget) {
    alert(s);
  } else {
    div.style.display = 'block';
    div.appendChild(document.createTextNode(s));
    div.appendChild(document.createElement("br"));
  }
}

//Node.prototype.getChildrenByTagName = function (tag) {
Object.prototype.getChildrenByTagName = function (tag) {
  var result = [];
  for (var j=0; j<this.childNodes.length; j++) {
    child = this.childNodes[j];
    if (child.nodeType == Node.ELEMENT_NODE && child.tagName == tag)
      result.push(child);
  }
  return result;
}

function getField(node, childtag, orank) {
  var fields = getFields(node, childtag, orank);
  if (fields.length > 0)
    return fields[0];
  else
    return null;
}

function getChild(node, childtag, orank) {
  var childnodes = getChildren(node, childtag, orank);
  if (childnodes.length > 0)
    return childnodes[0];
  else
    return null;
}

function getChildren(node, childtag, orank) {
  var childnodes = [];
  // first, fields from any occurrence rule
  if (orank != null && childtag != "rank" && childtag != "occurrence") {
    var occurnodes = getChildren(node, "occurrence", orank);
    for (var i=0; i<occurnodes.length; i++) {
      mapAttributes(occurnodes[i], ["rank"]);
      if (occurnodes[i].rank == undefined || occurnodes[i].rank.split(/,/).member(orank))
	childnodes = childnodes.concat(occurnodes[i].getChildrenByTagName(childtag));
    }
  }

  // then, the fields from the node itself
  childnodes = childnodes.concat(node.getChildrenByTagName(childtag));

  // then, any fields from the template
  if (childtag != "rank") {
    var rank = getField(node, "rank");
    if (rank != null) {
      if (index[rank] != undefined)
	childnodes = childnodes.concat(index[rank].getChildrenByTagName(childtag));
    }
  }
  return childnodes;
}

function getFields(node, childtag, orank) {
  var childnodes = getChildren(node, childtag, orank);
  var result = new Array(childnodes.length);
  for (var i=0; i<childnodes.length; i++)
    result[i] = childnodes[i].firstChild.nodeValue; // probably not exactly right
  return result;
}

// For Mozilla compatibility
function mapAttributes(node, attrs) {
  for (i=0; i<attrs.length; i++) {
    attr = attrs[i];
    if (node[attr] == undefined && node.hasAttribute(attr))
      node[attr] = node.getAttribute(attr);
  }
}

function min(x,y) {
  return x<y?x:y;
}
function max(x,y) {
  return x>y?x:y;
}

