/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/* import-globals-from calICALJSComponents.js */

var { ICAL, unwrapSetter, unwrapSingle, wrapGetter } = ChromeUtils.import(
  "resource://calendar/modules/ical.js"
);
var { cal } = ChromeUtils.import("resource://calendar/modules/calUtils.jsm");

function calIcalProperty(innerObject) {
  this.innerObject = innerObject || new ICAL.Property();
  this.wrappedJSObject = this;
}

calIcalProperty.prototype = {
  QueryInterface: ChromeUtils.generateQI([Ci.calIIcalProperty]),
  classID: Components.ID("{423ac3f0-f612-48b3-953f-47f7f8fd705b}"),

  get icalString() {
    return this.innerObject.toICALString() + ICAL.newLineChar;
  },
  get icalProperty() {
    return this.innerObject;
  },
  set icalProperty(val) {
    this.innerObject = val;
  },

  get parent() {
    return this.innerObject.parent;
  },
  toString: function() {
    return this.innerObject.toICAL();
  },

  get value() {
    // Unescaped value for properties of TEXT, escaped otherwise.
    if (this.innerObject.type == "text") {
      return this.innerObject.getValues().join(",");
    }
    return this.valueAsIcalString;
  },
  set value(val) {
    // Unescaped value for properties of TEXT, escaped otherwise.
    if (this.innerObject.type == "text") {
      this.innerObject.setValue(val);
      return val;
    }
    this.valueAsIcalString = val;
    return val;
  },

  get valueAsIcalString() {
    let type = this.innerObject.type;
    return this.innerObject
      .getValues()
      .map(val => {
        if (type == "text") {
          return ICAL.stringify.value(val, type, ICAL.design.icalendar);
        } else if (typeof val == "number" || typeof val == "string") {
          return val;
        } else if ("toICALString" in val) {
          return val.toICALString();
        } else {
          return val.toString();
        }
      })
      .join(",");
  },
  set valueAsIcalString(val) {
    let mockLine = this.propertyName + ":" + val;
    let prop = ICAL.Property.fromString(mockLine, ICAL.design.icalendar);

    if (this.innerObject.isMultiValue) {
      this.innerObject.setValues(prop.getValues());
    } else {
      this.innerObject.setValue(prop.getFirstValue());
    }
    return val;
  },

  get valueAsDatetime() {
    let val = this.innerObject.getFirstValue();
    let isIcalTime =
      val && typeof val == "object" && "icalclass" in val && val.icalclass == "icaltime";
    return isIcalTime ? new calDateTime(val) : null;
  },
  set valueAsDatetime(rawval) {
    unwrapSetter(
      ICAL.Time,
      rawval,
      function(val) {
        if (
          val &&
          val.zone &&
          val.zone != ICAL.Timezone.utcTimezone &&
          val.zone != ICAL.Timezone.localTimezone
        ) {
          this.innerObject.setParameter("TZID", val.zone.tzid);
          if (this.parent) {
            let tzref = wrapGetter(calICALJSTimezone, val.zone);
            this.parent.addTimezoneReference(tzref);
          }
        } else {
          this.innerObject.removeParameter("TZID");
        }
        this.innerObject.setValue(val);
      },
      this
    );
  },

  get propertyName() {
    return this.innerObject.name.toUpperCase();
  },

  getParameter: function(name) {
    // Unfortunately getting the "VALUE" parameter won't work, since in
    // jCal it has been translated to the value type id.
    if (name == "VALUE") {
      let defaultType = this.innerObject.getDefaultType();
      if (this.innerObject.type != defaultType) {
        // Default type doesn't match object type, so we have a VALUE
        // parameter
        return this.innerObject.type.toUpperCase();
      }
    }

    return this.innerObject.getParameter(name.toLowerCase());
  },
  setParameter: function(name, value) {
    // Similar problems for setting the value parameter. Lightning code
    // expects setting the value parameter to just change the value type
    // and attempt to use the previous value as the new one. To do this in
    // ICAL.js we need to save the value, reset the type and then try to
    // set the value again.
    if (name == "VALUE") {
      let oldValues;
      let type = this.innerObject.type;
      let designSet = this.innerObject._designSet;

      let wasMultiValue = this.innerObject.isMultiValue;
      if (wasMultiValue) {
        oldValues = this.innerObject.getValues();
      } else {
        let oldValue = this.innerObject.getFirstValue();
        oldValues = oldValue ? [oldValue] : [];
      }

      this.innerObject.resetType(value.toLowerCase());
      try {
        oldValues = oldValues.map(oldValue => {
          let strvalue = ICAL.stringify.value(oldValue.toString(), type, designSet);
          return ICAL.parse._parseValue(strvalue, value, designSet);
        });
      } catch (e) {
        // If there was an error reparsing the value, then just keep it
        // empty.
        oldValues = null;
      }

      if (oldValues && oldValues.length) {
        if (wasMultiValue && this.innerObject.isMultiValue) {
          this.innerObject.setValues(oldValues);
        } else {
          this.innerObject.setValue(oldValues.join(","));
        }
      }
    } else {
      this.innerObject.setParameter(name.toLowerCase(), value);
    }
  },
  removeParameter: function(name) {
    // Again, VALUE needs special handling. Removing the value parameter is
    // kind of like resetting it to the default type. So find out the
    // default type and then set the value parameter to it.
    if (name == "VALUE") {
      let propname = this.innerObject.name.toLowerCase();
      if (propname in ICAL.design.icalendar.property) {
        let details = ICAL.design.icalendar.property[propname];
        if ("defaultType" in details) {
          this.setParameter("VALUE", details.defaultType);
        }
      }
    } else {
      this.innerObject.removeParameter(name.toLowerCase());
    }
  },

  clearXParameters: function() {
    cal.WARN(
      "calIICSService::clearXParameters is no longer implemented, please use removeParameter"
    );
  },

  paramIterator: null,
  getFirstParameterName: function() {
    let innerObject = this.innerObject;
    this.paramIterator = (function*() {
      let defaultType = innerObject.getDefaultType();
      if (defaultType != innerObject.type) {
        yield "VALUE";
      }

      let paramNames = Object.keys(innerObject.jCal[1] || {});
      for (let name of paramNames) {
        yield name.toUpperCase();
      }
    })();
    return this.getNextParameterName();
  },

  getNextParameterName: function() {
    if (this.paramIterator) {
      let next = this.paramIterator.next();
      if (next.done) {
        this.paramIterator = null;
      }

      return next.value;
    } else {
      return this.getFirstParameterName();
    }
  },
};

function calIcalComponent(innerObject) {
  this.innerObject = innerObject || new ICAL.Component();
  this.wrappedJSObject = this;
  this.mReferencedZones = {};
}

calIcalComponent.prototype = {
  QueryInterface: ChromeUtils.generateQI([Ci.calIIcalComponent]),
  classID: Components.ID("{51ac96fd-1279-4439-a85b-6947b37f4cea}"),

  clone: function() {
    return new calIcalComponent(new ICAL.Component(this.innerObject.toJSON()));
  },

  get parent() {
    return wrapGetter(calIcalComponent, this.innerObject.parent);
  },

  get icalTimezone() {
    return this.innerObject.name == "vtimezone" ? this.innerObject : null;
  },
  get icalComponent() {
    return this.innerObject;
  },
  set icalComponent(val) {
    this.innerObject = val;
  },

  componentIterator: null,
  getFirstSubcomponent: function(kind) {
    if (kind == "ANY") {
      kind = null;
    } else if (kind) {
      kind = kind.toLowerCase();
    }
    let innerObject = this.innerObject;
    this.componentIterator = (function*() {
      let comps = innerObject.getAllSubcomponents(kind);
      if (comps) {
        for (let comp of comps) {
          yield new calIcalComponent(comp);
        }
      }
    })();
    return this.getNextSubcomponent(kind);
  },
  getNextSubcomponent: function(kind) {
    if (this.componentIterator) {
      let next = this.componentIterator.next();
      if (next.done) {
        this.componentIterator = null;
      }

      return next.value;
    } else {
      return this.getFirstSubcomponent(kind);
    }
  },

  get componentType() {
    return this.innerObject.name.toUpperCase();
  },

  get uid() {
    return this.innerObject.getFirstPropertyValue("uid");
  },
  set uid(val) {
    this.innerObject.updatePropertyWithValue("uid", val);
  },

  get prodid() {
    return this.innerObject.getFirstPropertyValue("prodid");
  },
  set prodid(val) {
    this.innerObject.updatePropertyWithValue("prodid", val);
  },

  get version() {
    return this.innerObject.getFirstPropertyValue("version");
  },
  set version(val) {
    this.innerObject.updatePropertyWithValue("version", val);
  },

  get method() {
    return this.innerObject.getFirstPropertyValue("method");
  },
  set method(val) {
    this.innerObject.updatePropertyWithValue("method", val);
  },

  get status() {
    return this.innerObject.getFirstPropertyValue("status");
  },
  set status(val) {
    this.innerObject.updatePropertyWithValue("status", val);
  },

  get summary() {
    return this.innerObject.getFirstPropertyValue("summary");
  },
  set summary(val) {
    this.innerObject.updatePropertyWithValue("summary", val);
  },

  get description() {
    return this.innerObject.getFirstPropertyValue("description");
  },
  set description(val) {
    this.innerObject.updatePropertyWithValue("description", val);
  },

  get location() {
    return this.innerObject.getFirstPropertyValue("location");
  },
  set location(val) {
    this.innerObject.updatePropertyWithValue("location", val);
  },

  get categories() {
    return this.innerObject.getFirstPropertyValue("categories");
  },
  set categories(val) {
    this.innerObject.updatePropertyWithValue("categories", val);
  },

  get URL() {
    return this.innerObject.getFirstPropertyValue("url");
  },
  set URL(val) {
    this.innerObject.updatePropertyWithValue("url", val);
  },

  get priority() {
    // If there is no value for this integer property, then we must return
    // the designated INVALID_VALUE.
    const INVALID_VALUE = Ci.calIIcalComponent.INVALID_VALUE;
    let prop = this.innerObject.getFirstProperty("priority");
    let val = prop ? prop.getFirstValue() : null;
    return val === null ? INVALID_VALUE : val;
  },
  set priority(val) {
    this.innerObject.updatePropertyWithValue("priority", val);
  },

  _setTimeAttr: function(propName, val) {
    let prop = this.innerObject.updatePropertyWithValue(propName, val);
    if (
      val &&
      val.zone &&
      val.zone != ICAL.Timezone.utcTimezone &&
      val.zone != ICAL.Timezone.localTimezone
    ) {
      prop.setParameter("TZID", val.zone.tzid);
      this.addTimezoneReference(wrapGetter(calICALJSTimezone, val.zone));
    } else {
      prop.removeParameter("TZID");
    }
  },

  get startTime() {
    return wrapGetter(calDateTime, this.innerObject.getFirstPropertyValue("dtstart"));
  },
  set startTime(val) {
    unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "dtstart"), this);
  },

  get endTime() {
    return wrapGetter(calDateTime, this.innerObject.getFirstPropertyValue("dtend"));
  },
  set endTime(val) {
    unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "dtend"), this);
  },

  get duration() {
    return wrapGetter(calDuration, this.innerObject.getFirstPropertyValue("duration"));
  },

  get dueTime() {
    return wrapGetter(calDateTime, this.innerObject.getFirstPropertyValue("due"));
  },
  set dueTime(val) {
    unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "due"), this);
  },

  get stampTime() {
    return wrapGetter(calDateTime, this.innerObject.getFirstPropertyValue("dtstamp"));
  },
  set stampTime(val) {
    unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "dtstamp"), this);
  },

  get createdTime() {
    return wrapGetter(calDateTime, this.innerObject.getFirstPropertyValue("created"));
  },
  set createdTime(val) {
    unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "created"), this);
  },

  get completedTime() {
    return wrapGetter(calDateTime, this.innerObject.getFirstPropertyValue("completed"));
  },
  set completedTime(val) {
    unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "completed"), this);
  },

  get lastModified() {
    return wrapGetter(calDateTime, this.innerObject.getFirstPropertyValue("last-modified"));
  },
  set lastModified(val) {
    unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "last-modified"), this);
  },

  get recurrenceId() {
    return wrapGetter(calDateTime, this.innerObject.getFirstPropertyValue("recurrence-id"));
  },
  set recurrenceId(val) {
    unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "recurrence-id"), this);
  },

  serializeToICS: function() {
    return this.innerObject.toString() + ICAL.newLineChar;
  },
  toString: function() {
    return this.innerObject.toString();
  },

  addSubcomponent: function(comp) {
    comp.getReferencedTimezones({}).forEach(this.addTimezoneReference, this);
    let jscomp = unwrapSingle(ICAL.Component, comp);
    this.innerObject.addSubcomponent(jscomp);
  },

  propertyIterator: null,
  getFirstProperty: function(kind) {
    if (kind == "ANY") {
      kind = null;
    } else if (kind) {
      kind = kind.toLowerCase();
    }
    let innerObject = this.innerObject;
    this.propertyIterator = (function*() {
      let props = innerObject.getAllProperties(kind);
      if (!props) {
        return;
      }
      for (let prop of props) {
        let hell = prop.getValues();
        if (hell.length > 1) {
          // Uh oh, multiple property values. Our code expects each as one
          // property. I hate API incompatibility!
          for (let devil of hell) {
            let thisprop = new ICAL.Property(prop.toJSON(), prop.parent);
            thisprop.removeAllValues();
            thisprop.setValue(devil);
            yield new calIcalProperty(thisprop);
          }
        } else {
          yield new calIcalProperty(prop);
        }
      }
    })();

    return this.getNextProperty(kind);
  },

  getNextProperty: function(kind) {
    if (this.propertyIterator) {
      let next = this.propertyIterator.next();
      if (next.done) {
        this.propertyIterator = null;
      }

      return next.value;
    } else {
      return this.getFirstProperty(kind);
    }
  },

  _getNextParentVCalendar: function() {
    let vcalendar = this; // eslint-disable-line consistent-this
    while (vcalendar && vcalendar.componentType != "VCALENDAR") {
      vcalendar = vcalendar.parent;
    }
    return vcalendar || this;
  },

  addProperty: function(prop) {
    try {
      let datetime = prop.valueAsDatetime;
      if (datetime && datetime.timezone) {
        this._getNextParentVCalendar().addTimezoneReference(datetime.timezone);
      }
    } catch (e) {
      // If there is an issue adding the timezone reference, don't make
      // that break adding the property.
    }

    let jsprop = unwrapSingle(ICAL.Property, prop);
    this.innerObject.addProperty(jsprop);
  },

  addTimezoneReference: function(timezone) {
    if (timezone) {
      if (!(timezone.tzid in this.mReferencedZones) && this.componentType == "VCALENDAR") {
        let comp = timezone.icalComponent;
        if (comp) {
          this.addSubcomponent(comp);
        }
      }

      this.mReferencedZones[timezone.tzid] = timezone;
    }
  },

  getReferencedTimezones: function(aCount) {
    let vals = Object.keys(this.mReferencedZones).map(timezone => this.mReferencedZones[timezone]);
    aCount.value = vals.length;
    return vals;
  },

  serializeToICSStream: function() {
    let unicodeConverter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].createInstance(
      Ci.nsIScriptableUnicodeConverter
    );
    unicodeConverter.charset = "UTF-8";
    return unicodeConverter.convertToInputStream(this.innerObject.toString());
  },
};

function calICSService() {
  this.wrappedJSObject = this;
}

calICSService.prototype = {
  QueryInterface: ChromeUtils.generateQI([Ci.calIICSService]),
  classID: Components.ID("{c61cb903-4408-41b3-bc22-da0b27efdfe1}"),

  parseICS: function(serialized, tzProvider) {
    // TODO ical.js doesn't support tz providers, but this is usually null
    // or our timezone service anyway.
    let comp = ICAL.parse(serialized);
    return new calIcalComponent(new ICAL.Component(comp));
  },

  parseICSAsync: function(serialized, tzProvider, listener) {
    // There are way too many error checking messages here, but I had so
    // much pain with this method that I don't want it to break again.
    try {
      let worker = new ChromeWorker("resource://calendar/calendar-js/calICSService-worker.js");
      worker.onmessage = function(event) {
        let rc = Cr.NS_ERROR_FAILURE;
        let icalComp = null;
        try {
          rc = event.data.rc;
          icalComp = new calIcalComponent(new ICAL.Component(event.data.data));
          if (!Components.isSuccessCode(rc)) {
            cal.ERROR("[calICSService] Error in parser worker: " + event.data);
          }
        } catch (e) {
          cal.ERROR("[calICSService] Exception parsing item: " + e);
        }

        listener.onParsingComplete(rc, icalComp);
      };
      worker.onerror = function(event) {
        cal.ERROR("[calICSService] Error in parser worker: " + event.message);
        listener.onParsingComplete(Cr.NS_ERROR_FAILURE, null);
      };
      worker.postMessage(serialized);
    } catch (e) {
      // If an error occurs above, the calling code will hang. Catch the exception just in case
      cal.ERROR("[calICSService] Error starting parsing worker: " + e);
      listener.onParsingComplete(Cr.NS_ERROR_FAILURE, null);
    }
  },

  createIcalComponent: function(kind) {
    return new calIcalComponent(new ICAL.Component(kind.toLowerCase()));
  },

  createIcalProperty: function(kind) {
    return new calIcalProperty(new ICAL.Property(kind.toLowerCase()));
  },

  createIcalPropertyFromString: function(str) {
    return new calIcalProperty(ICAL.Property.fromString(str.trim(), ICAL.design.icalendar));
  },
};
