/*
    $Revision$
    $Id$

    File:           ms_Graph.js

    Description:    MS Graph koppeling hulpfuncties
*/

/* GLOBALS */
var fso = new ActiveXObject("Scripting.FileSystemObject");
var oCrypto = new ActiveXObject("SLNKDWF.Crypto");
var S_msgraph_sync_level;
var CERT_ID_PREFIX = "FACILITOR.EXCHANGE.";
var FCLT_KEY_PROP_ID_NAME = "String {00020329-0000-0000-c000-000000000046} Name fclt.res_rsv_ruimte.key";
var REANIMATE_THRESHOLD_MS = 30000; // Niet reanimeren als langer dan 30s geleden verwijderd

/*////////////////////////////////
//                              //
//   Bearer token acquisition   //
//                              //
////////////////////////////////*/
function requestToken(config)
{
    setMSGraphSyncLevel();
    return requestTokenCC(config);
}

// Request bearer token using [C]lient [C]redentials
function requestTokenCC(config)
{
    if (config.certificate)
        return requestTokenCertificate(config);
    else
    {
        var data = 'grant_type=client_credentials'
                 + '&client_secret='    + config.client_secret
                 + '&client_id='        + config.client_id
                 + '&scope='            + 'https://graph.microsoft.com/.default';
        var request = {
            "method": "POST",
            "url": "https://login.microsoftonline.com/" + config.tenant + "/oauth2/v2.0/token",
            "body": data,
            "headers": { "Content-Type": "application/x-www-form-urlencoded" }
        }
        var xhr = doHTTP(request);
        return xhr ? xhr.access_token : null;
    }
}

// Request bearer token using a Certificate
function requestTokenCertificate(config)
{
    if (!unlockChilkat()) {
        return false;
    }
    var pfx = new ActiveXObject("Chilkat_9_5_0.Pfx");
    var success = pfx.LoadPfxFile(custabspath + "\\Exchange\\" + config.certificate, config.certificate_password);
    if (success != 1) {
        __DoLog("Error in LoadPfxFile", "#FF0000");
        __DoLog(pfx.LastErrorText);
        return null;
    }
    var privKey = pfx.GetPrivateKey(0);

    var jwt = new ActiveXObject("Chilkat_9_5_0.Jwt");
    var jose = new ActiveXObject("Chilkat_9_5_0.JsonObject");
    var thumbprint = "thumbprint" in config ? config.thumbprint : config.client_assertion.header.x5t;
    jose.AppendString("alg", "RS256");
    jose.AppendString("typ", "JWT");

    // x5t to base64
    jose.AppendString("x5t", oCrypto.hex2base64(thumbprint, false, true));

    var claims = new ActiveXObject("Chilkat_9_5_0.JsonObject");
    var jwtDateTime = new Date();

    claims.AppendString("iss", config.client_id);
    claims.AppendString("jti", getGUID()); // (Unique) JWT token identifier
    claims.AppendString("sub", config.client_id);
    claims.AppendString("aud", "https://login.microsoftonline.com/" + config.tenant + "/oauth2/v2.0/token");

    claims.AddIntAt(-1, "iat", Math.floor(jwtDateTime.getTime()/1000)); // Issued at time
    claims.AddIntAt(-1, "nbf", Math.floor(jwtDateTime.getTime()/1000)); // Not before
    jwtDateTime.setMinutes(jwtDateTime.getMinutes() + 10);
    claims.AddIntAt(-1, "exp", Math.floor(jwtDateTime.getTime()/1000)); // Expiration time

    var parms = 'grant_type=client_credentials'
              + '&client_id='               + config.client_id
              + '&scope='                   + 'https://graph.microsoft.com/.default'
              + '&client_assertion='        + jwt.CreateJwtPk(jose.Emit(), claims.Emit(), privKey)
              + '&client_assertion_type='   + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
    var request = {
        "method": "POST",
        "url": "https://login.microsoftonline.com/" + config.tenant + "/oauth2/v2.0/token",
        "body": parms,
        "headers": { "Content-Type": "application/x-www-form-urlencoded" }
    }
    var xhr = doHTTP(request);
    return xhr ? xhr.access_token : null;
}

/*////////////////////////////////
//                              //
//       Helper functions       //
//                              //
////////////////////////////////*/
function loadconfig(configpath)
{
    if (!fso.FileExists(configpath))
        return null;
    var f = fso.OpenTextFile(configpath, 1); // ForReading
    var config = eval("(" + f.ReadAll() + ")");
    f.Close();
    return config;
}

function setMSGraphSyncLevel()
{
    if (S_msgraph_sync_level !== null)
    {
        var sql = "SELECT COALESCE(fac_setting_pvalue, fac_setting_default) fac_setting_value"
                + "  FROM fac_setting"
                + " WHERE fac_setting_name = 'msgraph_sync_level'";
        var oRs = Oracle.Execute(sql);
        /* global */ S_msgraph_sync_level = oRs("fac_setting_value").Value;
        oRs.Close();
    }
}

function getMSGraphSyncLevel()
{
    if (S_msgraph_sync_level === null)
        setMSGraphSyncLevel();
    return S_msgraph_sync_level;
}

/* Retuns the dates in between Facilitor synchronises appointments for this room */
function getCalendarViewDates(res_ruimte_extern_id) {
    var dateFrom = new Date();
    dateFrom.setDate(dateFrom.getDate() - config.fullpast);
    dateFrom.setHours(0, 0, 0, 0);

    var dateTo = new Date();
    dateTo.setDate(dateTo.getDate() + config.fullfuture);
    dateTo.setHours(23, 59, 59, 999);

    var startdatum = null;
    var vervaldatum = null;

    var sql = "SELECT res_ruimte_startdatum, res_ruimte_vervaldatum"
            + "  FROM res_ruimte"
            + " WHERE UPPER(res_ruimte_extern_id) = " + safe.quoted_sql_upper(res_ruimte_extern_id)
            + "   AND res_ruimte_verwijder IS NULL";
    var oRs = Oracle.Execute(sql);
    if (!oRs.EoF) {
        startdatum      = oRs("res_ruimte_startdatum").Value;
        vervaldatum     = oRs("res_ruimte_vervaldatum").Value;
    }
    oRs.Close();

    if (startdatum !== null) {
        startdatum = new Date(startdatum);
    }
    if (vervaldatum !== null) {
        vervaldatum = new Date(vervaldatum);
    }
    if (startdatum !== null && startdatum.getTime() >= dateTo.getTime() ||        // Ruimte begint pas na de einddatum van de calenderView
        vervaldatum !== null && vervaldatum.getTime() <= dateFrom.getTime()) {    // Ruimte is vervallen voor de begindatum van de calendarView
        return false;   // Nothing to do
    }
    if (startdatum !== null && startdatum.getTime() > dateFrom.getTime()) {
        dateFrom = startdatum;
    }

    dateTo.setHours(0, 0, 0, 0); // Vergelijk op dag
    if (vervaldatum !== null && vervaldatum.getTime() <= dateTo.getTime()) {
        dateTo = new Date(vervaldatum.setDate(vervaldatum.getDate() - 1));
    }
    dateTo.setHours(23, 59, 59, 999);

    if (dateTo.getTime() <= dateFrom.getTime()) {
        return false;   // Nothing to do
    }

    return { "from": dateFrom, "to": dateTo };
}

/* Zoek de locaties bij dit event */
function getLocationsFromEvent(event) {
    var results = [];
    if (event) {
        var _pushUnique = function (record) {
            for (var i in results) {
                if (results[i].userPrincipalName === record.userPrincipalName) {
                    return; // Bestaat al
                }
            }
            if (record.userPrincipalName) {
                results.push(record);
            }
        }

        /* Zoek eerst bij de locations */
        if ("locations" in event) {
            for (var l in event.locations) {
                var locationUserPrincipalName = (event.locations[l].locationEmailAddress || event.locations[l].locationUri || event.locations[l].uniqueId);
                var accepted = false;
                if ("attendees" in event) {
                    for (var a in event.attendees) { // Staan we bij de attendees? Kijk dan of we al hebben geaccepteerd.
                        var attendeeUserPrincipalName = event.attendees[a].emailAddress.address;
                        if (attendeeUserPrincipalName === locationUserPrincipalName) {
                            accepted = event.attendees[a].status.response === "accepted";
                            break;
                        }
                    }
                }
                results.push({
                    "userPrincipalName": locationUserPrincipalName,
                    "accepted": accepted
                });
            }
        }

        /* Misschien staan we niet bij locations maar alleen bij attendees */
        if ("attendees" in event) {
            for (var a in event.attendees) {
                var attendeeUserPrincipalName = event.attendees[a].emailAddress.address;
                _pushUnique({
                    "userPrincipalName": attendeeUserPrincipalName,
                    "accepted": event.attendees[a].status.response === "accepted"
                });
            }
        }

        /* Verwijder nu alle false-positives */
        if (results.length) {
            var externId = [];
            for (var i in results) {
                externId.push(results[i].userPrincipalName.toUpperCase());
            }
            /* Haal alle geldige ruimte emailadressen op */
            var sql = "SELECT res_ruimte_extern_id FROM res_ruimte WHERE UPPER(res_ruimte_extern_id) IN (" + safe.quoted_sql_join(externId) + ")";
            var oRs = Oracle.Execute(sql);
            externId = [];
            while (!oRs.EoF) {
                externId.push(oRs("res_ruimte_extern_id").Value.toUpperCase());
                oRs.MoveNext();
            }
            oRs.Close();
            for (var i = results.length - 1; i >= 0; i--) {
                if (!inArray(results[i].userPrincipalName.toUpperCase(), externId)) {
                    results.splice(i, 1);
                }
            }
        }
    }
    return results;
}

/* Returns the event data */
function getCalendarEventByID(userPrincipalName, id)
{
    var request = {
        "method": "GET",
        "url": "https://graph.microsoft.com/v1.0/users/" + encodeURIComponent(userPrincipalName) + "/events/" + id
             + "?$expand=singleValueExtendedProperties($filter=id eq '" + FCLT_KEY_PROP_ID_NAME + "')",
        "headers": { "Authorization": "Bearer " + token }
    }
    return doHTTP(request);
}

/* Returns the event data */
function getCalendarEventByICalUId(userPrincipalName, iCalUId, isOccurrence)
{   // occurrences (en exceptions) zijn alleen via de calenderView beschikbaar, seriesMaster alleen via events (singleInstance via beiden)
    var endpoint = isOccurrence ? "calendarView" : "events"; // Het 'events' endpoint is veel goedkoper
    var url = "https://graph.microsoft.com/v1.0/users/" + encodeURIComponent(userPrincipalName) + "/" + endpoint + "?$filter=iCalUId eq '" + iCalUId + "'"
            + "&expand=singleValueExtendedProperties($filter=id eq '" + FCLT_KEY_PROP_ID_NAME + "')";
    if (isOccurrence) { // Dit endpoint heeft verplichte datum-parameters
        var calendarDates = getCalendarViewDates(userPrincipalName);
        if (!calendarDates) {
            __Log("No calendarView-window available");
            return null;
        }
        url += "&startDateTime=" + calendarDates.from.toISOString() + "&endDateTime=" + calendarDates.to.toISOString();
    }
    var request = {
        "method": "GET",
        "url": url,
        "headers": { "Authorization": "Bearer " + token }
    }
    var xhr = doHTTP(request);
    if (!isOccurrence && xhr && xhr.value && xhr.value.length === 0) { /* Niet gevonden? */
        return getCalendarEventByICalUId(userPrincipalName, iCalUId, true); // Heel soms is dit toch een exception, dan moeten we het via de calenderView proberen
    }
    if (xhr && xhr.value && xhr.value.length === 1) { /* Eenduidig gevonden? */
        return xhr.value[0];
    } else {
        return null;
    }
}

/* Returns the event data */
function getCalendarEventByRsv(userPrincipalName, res_rsv_ruimte_key)
{
    var request = {
        "method": "GET",
        "url": "https://graph.microsoft.com/v1.0/users/" + encodeURIComponent(userPrincipalName) + "/events?"
             + "$filter=singleValueExtendedProperties/Any(ep: ep/id eq '" + FCLT_KEY_PROP_ID_NAME + "' and ep/value eq '" + res_rsv_ruimte_key + "')",
        "headers": { "Authorization": "Bearer " + token }
    }
    var xhr = doHTTP(request);
    if (xhr && xhr.value && xhr.value.length === 1) { /* Eenduidig gevonden? */
        return xhr.value[0];
    } else {
        return null;
    }
}

/* Returns the event data */
function getCalendarEventsByDate(userPrincipalName, date_from, date_to)
{
    var request = {
        "method": "GET",
        "url": "https://graph.microsoft.com/v1.0/users/" + encodeURIComponent(userPrincipalName) + "/calendarView/delta?startDateTime=" + date_from + "&endDateTime=" + date_to,
        "headers": { "Authorization": "Bearer " + token }
    }
    return doHTTP(request);
}

/* Returns all changed events within the configurated timeframe */
function getCalendarItems(userPrincipalName, skiptoken, deltatoken)
{
    var parms;
    if (skiptoken) {            // Set parameters to read the next batch of events
        parms = "$skiptoken=" + skiptoken;
    } else if (deltatoken) {    // Set parameters to read first batch of changed events
        parms = "$deltatoken=" + deltatoken;
    } else {                    // Set timeframe parameters to read all changed events
        var calendarDates = getCalendarViewDates(userPrincipalName);
        if (!calendarDates) {
            __Log("No calendarView-window available; skipping full sync");
            return false;
        }
        parms = 'startDateTime=' + calendarDates.from.toISOString() + '&endDateTime=' + calendarDates.to.toISOString();
        __Log("Full syncing from " + calendarDates.from.toISOString() + " to " + calendarDates.to.toISOString() + " for user " + userPrincipalName
            + " (" + Math.ceil((calendarDates.to.getTime() - calendarDates.from.getTime() - (1000 * 3600)) / (1000 * 3600 * 24)) + " days)"); // 1u extra aftrekken om de wintertijd niet als dag te laten tellen
    }

    var request = {
        "method": "GET",
        "url": "https://graph.microsoft.com/v1.0/users/" + encodeURIComponent(userPrincipalName) + "/calendarView/delta?" + parms,
        "headers": { "Accept": "application/json", "Authorization": "Bearer " + token }
    }
    return doHTTP(request);
}

/* Returns the attendee-list of a specific event as an Array, optionally excluding a given attendee (or room-resource) */
function getAttendeeList(userPrincipalName, id, exclude)
{
    var request = {
        "method": "GET",
        "url": "https://graph.microsoft.com/v1.0/users/" + encodeURIComponent(userPrincipalName) + "/events/" + id,
        "headers": { "Authorization": "Bearer " + token }
    }
    var xhr = doHTTP(request);
    var attendees = [];
    if (xhr) {
        attendees = xhr.attendees || [];
        if (exclude) {
            for (var i = 0; i < attendees.length; i++) {
                if (attendees[i].emailAddress.address.toUpperCase() == exclude.toUpperCase()) {
                    attendees.splice(i, 1);
                }
            }
        }
    }
    return attendees;
}

/* Returns all known subscriptions */
function getSubscriptions(skiptoken)
{
    var parms = "";
    if (typeof skiptoken !== "undefined") { // Set parameters to read the next batch of events
        parms = "?$skiptoken=" + skiptoken;
    }
    var request = {
        "method": "GET",
        "url": "https://graph.microsoft.com/v1.0/subscriptions" + parms,
        "headers": { "Accept": "application/json", "Authorization": "Bearer " + token }
    }
    return doHTTP(request);
}

/* Returns the notificationUrl used by MS Graph to send Facilitor notifications about changes in resources on which we are subscribed */
function getNotificationUrl(customerId)
{
    if (!customerId) {
        return false;
    }
    var hookurl;
    if (config.hookurl) {
        hookurl = config.hookurl;
    } else {   // Autodetect ngrok started with: ngrok http sggr.facws001.sg.nl:80
        var fac_version = custabspath.split("\\")[custabspath.split("\\").length - 3]; // 'Branch#####' of 'Trunk', 'APPL' op productie, maar die komt hier niet
        var request = {
            "method": "GET",
            "url": "http://127.0.0.1:4040/api/tunnels/command_line",
            "headers": { "Accept": "application/json" },
            "params": {
                "callback": function (request, response) {
                    __Log("Ngrok detected: " + response.public_url);
                    __Log("           tip: view traffic at http://127.0.0.1:4040");
                    hookurl = response.public_url + "/" + fac_version + "/";
                }
            }
        }
        doHTTP(request);
    }
    if (!hookurl) {
        _AiAi("INTERNAL_ERROR attempting to subscribe without a valid hookurl");
    }

    var apikey;
    var apiname = "MSGRAPHNOTIFICATION";
    var sql = "SELECT prs_perslid_apikey FROM prs_perslid WHERE prs_perslid_oslogin = '_MSGRAPHNOTIFICATION'";
    var oRs = Oracle.Execute(sql);
    if (!oRs.EoF) {
        apikey = oRs("prs_perslid_apikey").Value;
    } else {
        __DoLog("Error: There exists no user with login '_MSGRAPHNOTIFICATION'", "#FF0000");
    }
    oRs.Close();
    if (apikey == null) {
        __DoLog("Error: The user with login '_MSGRAPHNOTIFICATION' does not have an API-key configured", "#FF0000");
        return false;
    }
    return hookurl + "?API={0}&APIKEY={1}&fac_id={2}".format(apiname, apikey, customerId); // fac_id is vooral voor ngrok nodig
}

function validNotificationUrl(url) {
    // Test zelf ook even de hookurl direct op dezelfde manier als MS-Graph dat doen.
    // Zelf kunnen we betere foutmeldingen geven
    var validationToken = "Validation: testing MS Graph <-> notificationUrl connection (" + new Date().toISOString() + ")";
    __Log("\nPre-testing webhook url (like MS Graph will do soon): ");
    __Log("get "+ url + "&validationToken=" + encodeURIComponent(validationToken));
    var request = {
        "method": "POST",
        "url": url + "&validationToken=" + encodeURIComponent(validationToken),
        "headers": {"Accept": "application/json" },
        "params": {
            "callback": function (request, response) {
                return response.responseText == validationToken;
            }
        }
    }
    return doHTTP(request);
}

function getSyncToken(zaalemail) {
    var token = null;

    // Zoek token op
    var sql = "SELECT s.res_ruimte_syncstate" // syncstate = deltatoken
            + "  FROM res_ruimte r"
            + "     , res_ruimte_sync s"
            + " WHERE r.res_ruimte_key = s.res_ruimte_key(+)"
            + "   AND r.res_ruimte_verwijder IS NULL"
            + "   AND UPPER(res_ruimte_extern_id) = " + safe.quoted_sql_upper(zaalemail);
    var oRs = Oracle.Execute(sql);
    if (!oRs.EoF) {
        token = oRs("res_ruimte_syncstate").Value;
    }
    oRs.Close();
    return token;
}

function upsertSyncToken(zaalemail, token) {
    var res_ruimte_key;
    if (!isNaN(zaalemail) && typeof zaalemail === "number") { // De res_ruimte_key is al meegegeven (ipv de zaalemail)
        res_ruimte_key = zaalemail;
    } else if (typeof zaalemail === "string") { // Bepaal res_ruimte_key
        var sql = "SELECT res_ruimte_key"
                + "  FROM res_ruimte"
                + " WHERE res_ruimte_verwijder IS NULL"
                + "   AND UPPER(res_ruimte_extern_id) = " + safe.quoted_sql_upper(zaalemail);
        var oRs = Oracle.Execute(sql);
        if (oRs.EoF) {
            __DoLog("Error: Ruimte met extern id '" + zaalemail + "' niet gevonden");
            oRs.Close();
            return false;
        }
        res_ruimte_key = oRs("res_ruimte_key").Value;
        oRs.Close();
    } else {
        __DoLog("Error: Invalid type zaalemail '" + zaalemail + "' [" + (typeof zaalemail) + "]");
        return false;
    }

    // Bestaat er voor deze ruimte al een syncstate?
    sql = "SELECT 'dummy'"
        + "  FROM res_ruimte_sync"
        + " WHERE res_ruimte_key = " + res_ruimte_key;
    oRs = Oracle.Execute(sql);
    var isNew = oRs.EoF;
    oRs.Close();

    // Upsert de syncstate
    if (isNew) { // Deze ruimte wordt voor het eerst gekoppeld, maak een res_ruimte_sync-record aan
        sql = "INSERT INTO res_ruimte_sync (res_ruimte_key,"
            + "                             res_ruimte_syncstate,"
            + "                             res_ruimte_syncdate)"
            + "    VALUES (" + res_ruimte_key + ", " + safe.quoted_sql(token) + ", SYSDATE)";
    } else { // De res_ruimte_sync-record bestaan al voor deze ruimte, update de syncstate/deltatoken
        sql = "UPDATE res_ruimte_sync"
            + "   SET res_ruimte_syncstate = " + safe.quoted_sql(token)
            + "     , res_ruimte_syncdate = SYSDATE"
            + " WHERE res_ruimte_key = " + res_ruimte_key;
    }
    Oracle.Execute(sql);
    return true;
}

/*  Mogelijk is deze notificatie een @removed/isCancelled-notificatie
    In deze functie valideren we dat deze notificaties ook zouden moeten leiden tot een Facilitor [D]elete
    Mogelijk is er nl. alleen van ruimte gewisseld, en moeten we de reservering niet weggooien
    *Deze functie werkt momenteel alleen voor Facilitor ruimte wijzigingen
*/
function validateNotification(userPrincipalName, roomCalendarEvent) {
    if ((getMSGraphSyncLevel() & 4) && roomCalendarEvent.isCancelled) {
        // Mogelijk slechts van ruimte gewisseld, kunnen we dit event nog terugvinden bij de organisator en zit er een Facilitor-ruimte bij deze afspraak?
        var organizerCalendarEvent = getCalendarEventByICalUId(roomCalendarEvent.organizer.emailAddress.address, roomCalendarEvent.iCalUId, roomCalendarEvent.seriesMasterId);
        if (organizerCalendarEvent && organizerCalendarEvent.isCancelled === false && organizerCalendarEvent.attendees && organizerCalendarEvent.attendees.length) {
            for (var i = 0; i < organizerCalendarEvent.attendees.length; i++) {
                var attendee = organizerCalendarEvent.attendees[i];
                if (attendee.type === "resource" && attendee.emailAddress.address != userPrincipalName && attendee.status.response !== "declined") { // Status none/notResponded/accepted krijgen we via een andere notificatie binnen en verwerken we daar/later
                    var sql = "SELECT '' FROM res_ruimte WHERE UPPER(res_ruimte_extern_id) = " + safe.quoted_sql_upper(attendee.emailAddress.address);
                    var oRs = Oracle.Execute(sql);
                    var roomChanged = !oRs.EoF; // Ruimte is gewisseld naar een bestaande Facilitor ruimte; negeer deze delete
                    oRs.Close();
                    if (roomChanged) {
                        return false;
                    }
                }
            }
        }
    }
    return true;
}

/* Zo wordt het externnr in Facilitor opgeslagen */
function getExternnrFromEvent(thisEvent, params) {
    params = params || {};

    var externnr;
    // Bepaal externnr
    switch (thisEvent.type) {
        // case "seriesMaster": // Komt hier niet voor
        // case "occurrence":   // Komt hier niet voor
        case "exception": // We doen hier niets met iCalUId want dat gebruiken we alleen voor room-changes, en dat ondersteunen we niet in Outlook-series
            externnr = thisEvent.seriesMasterId + "|" + thisEvent.id;
            break;
        case "singleInstance":
            externnr = (params.iCalUId ? "##iCalUId##" + thisEvent.iCalUId : thisEvent.id) + "|";
            break;
    }
    return externnr;
}

/*
    Update het Facilitor res_rsv_ruimte-record met link-data/id's
    Returns the new res_rsv_ruimte_externnr
*/
function updateFcltRecord(res_rsv_ruimte_key, thisEvent, externnr2, params) {
    var externnr;
    params = params || {};
    var sql = "UPDATE res_rsv_ruimte"
            + "   SET res_rsv_ruimte_externsyncdate = SYSDATE";
    if (thisEvent) {
        externnr = getExternnrFromEvent(thisEvent, params);
        sql += "    , res_rsv_ruimte_externnr = "
             + (params.force === false
                ? "       (CASE"
                  + "          WHEN EXISTS (SELECT 'dummy' FROM res_rsv_ruimte WHERE res_rsv_ruimte_externnr = " + safe.quoted_sql(externnr) + ")"
                  + "          THEN res_rsv_ruimte_externnr" // Laat ongewijzigd
                  + "          ELSE " + safe.quoted_sql(externnr)
                  + "      END)"
                : safe.quoted_sql(externnr));
    } else if (thisEvent === null) { // Expliciet null maakt het veld leeg
        sql += "    , res_rsv_ruimte_externnr = NULL"
    }
    if (getMSGraphSyncLevel() & 4) {
        if (externnr2) {
            sql += ", res_rsv_ruimte_externnr2 = " + safe.quoted_sql(externnr2);
        } else {
            sql += ", res_rsv_ruimte_externnr2 = "
                 + "      (CASE"
                 + "           WHEN res_rsv_ruimte_externnr2 = '##PENDING##'"
                 + "           THEN NULL"   // Herstel de koppeling van deze reservering
                 + "           ELSE res_rsv_ruimte_externnr2"
                 + "       END)";
        }
    }
    sql += " WHERE res_rsv_ruimte_key = " + res_rsv_ruimte_key;
    if ("onlyPending" in params && params.onlyPending) { // Vanuit _resToGraphCallback willen we alleen gegevens aanvullen, niet wijzigen als dit toevallig al is ingevuld door een notificatie
        sql += " AND res_rsv_ruimte_externnr2 = '##PENDING##'";
    }
    Oracle.Execute(sql);
    return typeof externnr === undefined || externnr;
}

/*
    Heeft dit roomCalendarEvent een Facilitor key aan boord die we kunnen matchen?
*/
function getFcltKeyFromEvent(roomCalendarEvent) {
    if ("singleValueExtendedProperties" in roomCalendarEvent) {
        for (var i = 0; i < roomCalendarEvent.singleValueExtendedProperties.length; i++) {
            if (roomCalendarEvent.singleValueExtendedProperties[i].id === FCLT_KEY_PROP_ID_NAME) {  // Key gevonden
                return parseInt(roomCalendarEvent.singleValueExtendedProperties[i].value, 10); // => res_rsv_ruimte_key
            }
        }
    }
    return -1;
}

/* Pas de FCLT ruimte aan indien nodig */
function checkUpdateRoomchange(locationsFromEvent, recordset) {
    if (locationsFromEvent.length === 1 && (recordset("res_ruimte_extern_id").Value === null || locationsFromEvent[0].userPrincipalName.toUpperCase() !== recordset("res_ruimte_extern_id").Value.toUpperCase())) { // De ruimte in Exchange != de gematchte FCLT ruimte; pas aan indien bekend
        var zaalChangeSql = "UPDATE res_rsv_ruimte"
                          + "   SET res_ruimte_opstel_key = "
                          + "      (CASE"
                          + "           WHEN (SELECT exc.getOpstelling (" + safe.quoted_sql(locationsFromEvent[0].userPrincipalName) + ") FROM DUAL) IS NOT NULL"
                          + "           THEN (SELECT exc.getOpstelling (" + safe.quoted_sql(locationsFromEvent[0].userPrincipalName) + ") FROM DUAL)"
                          + "           ELSE res_ruimte_opstel_key"
                          + "       END)"
                          + (locationsFromEvent[0].accepted
                            ? ""
                            : "   , res_rsv_ruimte_verwijder = SYSDATE") /* Als de ruimte accepteert wordt deze wel gereanimeerd, maar dan staat de ruimte iig al goed (anders ontvangen we notificaties op een andere ruimte, en kunnen we de afspraak niet meer terugvinden) */
                          + " WHERE res_rsv_ruimte_key = " + recordset("res_rsv_ruimte_key").Value;
        Oracle.Execute(zaalChangeSql);
        // De rest (dirty-state, objecten & artikelen) wordt met de import wel rechtgetrokken, bezoekers niet, deleted = deleted
        return locationsFromEvent[0].accepted;
    }
    return true;
}

/* Undelete een reservering incl. bijbehorenden */
function undeleteFclt(res_rsv_ruimte_key) {

    /* Reanimeer de Delen */
    sql = "UPDATE res_rsv_deel"
        + "   SET res_rsv_deel_verwijder = NULL"
        + " WHERE res_rsv_ruimte_key = " + res_rsv_ruimte_key
        + "   AND res_rsv_deel_verwijder = (SELECT res_rsv_ruimte_verwijder"
        + "                                   FROM res_rsv_ruimte"
        + "                                  WHERE res_rsv_ruimte_key = " + res_rsv_ruimte_key + ")";
    Oracle.Execute(sql);

    /* Reanimeer de Artikelen */
    sql = "UPDATE res_rsv_artikel"
        + "   SET res_rsv_artikel_verwijder = NULL"
        + " WHERE res_rsv_ruimte_key = " + res_rsv_ruimte_key
        + "   AND res_rsv_artikel_verwijder = (SELECT res_rsv_ruimte_verwijder"
        + "                                     FROM res_rsv_ruimte"
        + "                                    WHERE res_rsv_ruimte_key = " + res_rsv_ruimte_key + ")";
    Oracle.Execute(sql);

    /* Reanimeer de Reservering */
    var sql = "UPDATE res_rsv_ruimte"
            + "   SET res_rsv_ruimte_externsyncdate = SYSDATE"
            + "     , res_rsv_ruimte_verwijder = NULL"
            + "     , res_status_fo_key = 2"
            + " WHERE res_rsv_ruimte_key = " + res_rsv_ruimte_key;
    Oracle.Execute(sql);

    // Afspraken zijn hard verwijderd, dat kan dus niet meer hersteld worden
}

/* Test of de ruimte uberhaupt al geaccepteerd heeft */
function eventReadyForProcessing(userPrincipalName, thisEvent) {
    return true;
    if (!thisEvent.isOrganizer && ("attendees" in thisEvent)) { // Een notificatie van een uitgenodigde ruimte
        for (var i in thisEvent.attendees) {
            var attendee = thisEvent.attendees[i];
            if (attendee.emailAddress.address === userPrincipalName && attendee.status.response != "accepted") { // Er is nog helemaal niet geaccepteerd
                return false;
            }
        }
    }
    return true;
}

/*  Alleen bij S(msgraph_sync_level) & 4
    Deze functie wordt aangeroepen na het ontvangen van een notificatie van een event die in Facilitor nog niet bekend is
    We proberen de notificatie te matchen met een Facilitor reservering (als die bestaat) en te complementeren met het externnr
    Reserveringen vanuit Outlook gebruiken deze functie niet, die hebben al direct bij het aanmaken een externnr
*/
function updateExternnr(userPrincipalName, id)
{
    var roomCalendarEvent = getCalendarEventByID(userPrincipalName, id);
    if (!roomCalendarEvent || !validateNotification(userPrincipalName, roomCalendarEvent)) {
        return false;
    }

    if (roomCalendarEvent.type === "seriesMaster") { // Niets te updaten, we registreren geen seriesMaster's in Facilitor, de occurrences volgen hierna
        return null;
    }

    if (!eventReadyForProcessing(userPrincipalName, roomCalendarEvent)) {
        return -1; // 'Errorcode'; -> Stop this import
    }

    /* Kunnen we de notificatie matchen met een Facilitor reservering die zojuist is gemaakt op basis van de res_rsv_ruimte_key? */
    var res_rsv_ruimte_key = getFcltKeyFromEvent(roomCalendarEvent);
    var externnr = null;
    var sql = "SELECT rr.res_rsv_ruimte_key"
            + "     , rr.res_rsv_ruimte_verwijder"
            + "     , r.res_ruimte_extern_id"
            + "  FROM res_rsv_ruimte rr"
            + "     , res_ruimte_opstelling ro"
            + "     , res_ruimte r"
            + " WHERE ro.res_ruimte_opstel_key = rr.res_ruimte_opstel_key"
            + "   AND r.res_ruimte_key = ro.res_ruimte_key";
    if (res_rsv_ruimte_key > -1) { // Het event bevat een Facilitor res_rsv_ruimte_key, zoek daarop
        sql += "  AND rr.res_rsv_ruimte_key = " + res_rsv_ruimte_key;
    } else if (roomCalendarEvent.type === "singleInstance") { // Probeer dan te matchen op iCalUId
        sql += "  AND rr.res_rsv_ruimte_externnr = " + safe.quoted_sql("##iCalUId##" + roomCalendarEvent.iCalUId + "|");
    } else {
        sql += "  AND 1 = 0";
    }
    var oRs = Oracle.Execute(sql);
    if (!oRs.EoF) { // Gevonden
        var isDeleted = oRs("res_rsv_ruimte_verwijder").Value !== null;
        var locationsFromEvent = getLocationsFromEvent(roomCalendarEvent);
        var reanimate = !(roomCalendarEvent["@removed"] || roomCalendarEvent.isCancelled) &&
                        isDeleted &&
                        locationsFromEvent.length === 1 && // Alleen als er maar 1 Facilitor ruimte aan deze afspraak is gekoppeld
                        new Date().getTime() - new Date(oRs("res_rsv_ruimte_verwijder").Value).getTime() < REANIMATE_THRESHOLD_MS;
        if (reanimate) {
            undeleteFclt(oRs("res_rsv_ruimte_key").Value);
        } else if (isDeleted) { // 'Grace period' van een soft delete overschreden; verwijder link onder water en ga verder met nieuwe afspraak
            if (res_rsv_ruimte_key > -1) { // Haal de rsv-key weg bij het event; we gaan deze nooit meer gebruiken.
                patchEventWithFcltKey(roomCalendarEvent);
            }
            updateFcltRecord(oRs("res_rsv_ruimte_key").Value, null); // Verwijder de opgeslagen linked id's permanent uit Facilitor
            oRs.Close();
            return null; // Niets (relevants) gevonden, dus niets te updaten
        } else if (res_rsv_ruimte_key === -1 && !(roomCalendarEvent["@removed"] || roomCalendarEvent.isCancelled)) { // Gevonden, het actuele event bevat nog geen Facilitor res_rsv_ruimte_key, patch dat direct maar
            patchEventWithFcltKey(roomCalendarEvent, oRs("res_rsv_ruimte_key").Value);
        }
        var readyForUpdate = checkUpdateRoomchange(locationsFromEvent, oRs); // = true als ruimte is aangepast en de nieuwe ruimte al geaccepteert heeft, of als de ruimte niet is aangepast
        if (readyForUpdate) {
            externnr = updateFcltRecord(oRs("res_rsv_ruimte_key").Value, roomCalendarEvent);
        } else {
            externnr = -1; // skip; als de nieuwe ruimte nog niet heeft geaccepteert, willen we het iCalUId laten staan voor als we later willen reanimeren
        }
    }
    oRs.Close();
    return externnr;
}

/*
    Attempts to update the Facilitor-rsv-record with the organizer-event-id
    Only called when S(msgraph_sync_level) & 4
*/
function updateExternnr2(userPrincipalName, id, hostPrincipalName)
{
    var roomCalendarEvent = getCalendarEventByID(userPrincipalName, id);
    if (!roomCalendarEvent || roomCalendarEvent.type === "seriesMaster" /* Hier doet Facilitor niets mee */)
        return "";
    var organizerCalendarEvent = getCalendarEventByICalUId(hostPrincipalName, roomCalendarEvent.iCalUId, roomCalendarEvent.seriesMasterId);
    if (!organizerCalendarEvent)
        return "";
    var matchByExternnr;
    if (roomCalendarEvent.type == "singleInstance") {
        matchByExternnr = "res_rsv_ruimte_externnr = " + safe.quoted_sql(id + "|"); // Exact
    } else { // occurrence of exception
        var occurrenceId = id.replace(/_/g, '\\_');
        matchByExternnr = "res_rsv_ruimte_externnr LIKE " + safe.quoted_sql("%|" + occurrenceId) + " ESCAPE '\\'"; // LIKE
    }
    id = id.replace(/_/g, '\\_'); // Escape de underscore
    var sql = "UPDATE res_rsv_ruimte"
            + "   SET res_rsv_ruimte_externnr2 = " + safe.quoted_sql(organizerCalendarEvent.id)
            + " WHERE " + matchByExternnr;
    Oracle.Execute(sql);
    return organizerCalendarEvent.id;
}

/* Patch het Exchange event met de Facilitor res_rsv_ruimte_key */
function patchEventWithFcltKey(roomCalendarEvent, res_rsv_ruimte_key) {
    var userPrincipalName = roomCalendarEvent.organizer.emailAddress.address;
    var organisatorEvent = getCalendarEventByICalUId(userPrincipalName, roomCalendarEvent.iCalUId, roomCalendarEvent.seriesMasterId);
    if (!organisatorEvent)
        return "";
    var data = {
        "singleValueExtendedProperties": [{
            "id": FCLT_KEY_PROP_ID_NAME,
            "value": "-1"
        }]
    }
    if (res_rsv_ruimte_key) {
        data.singleValueExtendedProperties[0].value = String(res_rsv_ruimte_key);
    }
    var request = {
        "method": "PATCH",
        "url": "https://graph.microsoft.com/v1.0/users/" + encodeURIComponent(userPrincipalName) + "/events/" + organisatorEvent.id,
        "body": JSON.stringify(data),
        "headers": { "Content-Type": "application/json", "Authorization": "Bearer " + token }
    }
    var result = doHTTP(request);
    return result;
}

/*////////////////////////////////
//                              //
//        Main functions        //
//                              //
////////////////////////////////*/
function doCheckExpiringAuth()
{
    if (!unlockChilkat()) {
        return false;
    }
    var certificates = new ActiveXObject("Chilkat_9_5_0.CertStore");
    var success = certificates.LoadPfxFile(custabspath + "\\Exchange\\" + config.certificate, config.certificate_password);
    if (success) {
        var cert = certificates.GetCertificate(0);
        var expirationDate = new Date(cert.ValidToStr);
        var expDays = Math.floor((expirationDate.getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24); // Days untill expiration
        if (expDays < 30) {
            var txt = "Uw Outlook koppeling certificaat verloopt over " + expDays + " dagen. Neem contact op met Facilitor voor een nieuw certificaat";
            var sql = "BEGIN fac.putsystemnotification({0}, 3); END;".format(safe.quoted_sql(txt)); // 3=portal+email
            Oracle.Execute(sql);
        }
    }
    return true;
}

function syncPending(res_ruimte_key) {
    if (!(getMSGraphSyncLevel() & 4)) { // Alleen voor de volledige koppeling
        return false;
    }
    // Zoek de ##PENDING## reserveringen op
    var sql = _getSql("C")
            + "       AND rrr.res_rsv_ruimte_externnr2 = '##PENDING##'";
    if (res_ruimte_key > 0) {
        sql += "       AND rr.res_ruimte_key = " + res_ruimte_key
    }
    var oRs = Oracle.Execute(sql);
    while (!oRs.EoF) {
        var rsv_ruimte_key = oRs("res_rsv_ruimte_key").Value;
        var thisEvent = getCalendarEventByRsv(oRs("host_mail").Value, rsv_ruimte_key); // Is deze afspraak al wel bekend in Exchange?
        if (thisEvent === null) { /* Niet gevonden */
            _resToGraph(oRs, "C"); // Dan maken we hem bij deze alsnog aan
        } else { /* We hebben hem toch gevonden! Link ze aan elkaar; */
            updateFcltRecord(rsv_ruimte_key, thisEvent, thisEvent.id, { "iCalUId": true });
        }
        oRs.moveNext();
    }
    oRs.Close();
    return true;
}

// Vraagt om Rich notifications afh. van res_ruimte_external_flags
function enrichSubscription(userPrincipalName, data) {
    var sql = "SELECT res_ruimte_extern_flags" // syncstate = deltatoken
            + "  FROM res_ruimte"
            + " WHERE res_ruimte_verwijder IS NULL"
            + "   AND UPPER(res_ruimte_extern_id) = " + safe.quoted_sql_upper(userPrincipalName);
    var oRs = Oracle.Execute(sql);
    var externalFlag = oRs("res_ruimte_extern_flags").Value;
    oRs.Close();
    if ((externalFlag & 1) && ("certificate" in config)) { // Use Rich notifications
        var pfx = new ActiveXObject("Chilkat_9_5_0.Pfx");
        var success = pfx.LoadPfxFile(custabspath + "\\Exchange\\" + config.certificate, config.certificate_password);
        if (success != 1) {
            __DoLog("Error in LoadPfxFile", "#FF0000");
            __DoLog(pfx.LastErrorText);
            return data;
        }
        var certificate = pfx.GetCert(0);
        var BASE64_PUBLIC_KEY = certificate.GetEncoded();

        var $filter = "?$select=createdDateTime,changeKey,iCalUId,subject,isCancelled,isOrganizer,sensitivity,seriesMasterId,type,isOnlineMeeting,responseStatus,start,end,locations,attendees,organizer";
        data.resource += $filter;
        data.includeResourceData = true;
        data.encryptionCertificate = BASE64_PUBLIC_KEY;
        data.encryptionCertificateId = CERT_ID_PREFIX + customerId.toUpperCase();
    }
    return data;
}

// Let op, deze functie doet eerst een full-sync
function createSubscription(userPrincipalName, res_ruimte_key, notificationUrl, urlValidated) {
    if (!urlValidated && !validNotificationUrl(notificationUrl)) {
        return false;
    }

    // Ververs syncstate (=maak leeg) zodat we niet de incremental changes binnenhalen, maar de hele calendarView
    upsertSyncToken(+res_ruimte_key, null);

    // Nu full-'sync'en, dan hebben we zsm een deltaToken waarmee we alleen mutaties kunnen ophalen
    doImport(res_ruimte_key, userPrincipalName);

    // Als we niets van deze ruimte willen synchroniseren, hoeven we ook niet te subscriben
    if (getCalendarViewDates(userPrincipalName) === false) {
        __Log("Aborting subscription for " + userPrincipalName);
        return false; // Er is geen calendarView-window waarbinnen wij synchroniseren (de ruimte is vervallen of de startdatum ligt te ver in de toekomst)
    }

    // En dan nu de subscription aanmaken
    var expirationDate = new Date();
    expirationDate.setDate(expirationDate.getDate() + 2); // 2 dagen (max. is 4.320 minuten = 3 dagen)
    /* (!) Subscription met includeResourceData zijn maximaal 1 dag geldig (https://learn.microsoft.com/en-us/graph/outlook-change-notifications-overview) */

    __Log("Creating hook for " + userPrincipalName);

    var changeType = "updated";
    var clientState = oCrypto.hex_random(20);

    // Alle afspraken komen binnen met de "updated"-notificatie (de update is nl. dat de ruimte de afspraak heeft geaccepteerd),
    // behalve afspraken die direct in een ruimte-agenda worden geboekt want die hoeven niet eerst geaccepteerd te worden.
    // Die afspraken willen we alleen in Facilitor importeren als we een fallback-user hebben geconfigureerd, anders niet.
    var sql = "SELECT prs_perslid_email FROM prs_v_aanwezigperslid WHERE prs_perslid_oslogin = " + safe.quoted_sql_upper("_MSGRAPH_FALLBACK_ROOMS");
    var oRs = Oracle.Execute(sql);
    if (!oRs.EoF && oRs("prs_perslid_email").Value) {
        changeType = "created,updated";
    }
    oRs.Close();

    var data = {
        "changeType"        : changeType,
        "notificationUrl"   : notificationUrl + "&res_ruimte=" + res_ruimte_key + "&hookcreated=" + new Date().toISOString(),
        "resource"          : "/users/" + encodeURIComponent(userPrincipalName) + "/events/",
        "clientState"       : clientState,
        "expirationDateTime": expirationDate
    }

    // Vraag potentieel om Rich notifications
    data = enrichSubscription(userPrincipalName, data);

    var newSubscriptionCallback = function _newSubscriptionCallback(request, response) {
        __Log("Gelukt");
        __Log(JSON.stringify(response, null, 2));

        // Update de data aangaande de (ver)nieuw(d)e subscription
        sql = "UPDATE res_ruimte"
            + "   SET res_ruimte_graphhooksecret = " + safe.quoted_sql(clientState) // Met dit secret verifieren we dat requests van MS Graph ook echt van MS Graph zijn
            + "     , res_ruimte_externsyncdate = SYSDATE"  // Dit is de datum waarop de subscription is aangemaakt
            + " WHERE res_ruimte_key = " + res_ruimte_key;
        Oracle.Execute(sql);

        // Kijk nu of we nog pending (facilitor)reserveringen hebben en corrigeer die
        syncPending(res_ruimte_key);

        return true;
    }

    var request = {
        "method": "POST",
        "url": "https://graph.microsoft.com/v1.0/subscriptions",
        "body": JSON.stringify(data),
        "headers": {
            "Content-Type": "application/json",
            "Authorization": "Bearer " + token
        },
        "params": { "callback": newSubscriptionCallback }
    }
    return doHTTP(request);
}

function deleteSubscription(userPrincipalName) {
    var response = getSubscriptions();
    var subscriptionList = [];
    if (response && response.value) {
        var resource = "/users/" + encodeURIComponent(userPrincipalName) + "/events";
        for (var i = 0; i < response.value.length; i++) {
            if (response.value[i].resource == resource) { // Filter op Extern ID
                subscriptionList.push(response.value[i].id);
            }
        }
        while (response.skiptoken) {
            response = getSubscriptions(response.skiptoken);
            for (var i = 0; i < response.value.length; i++) {
                if (response.value[i].resource == resource) { // Filter op Extern ID
                    subscriptionList.push(response.value[i].id);
                }
            }
        }
    }
    var success = true;
    for (var i = 0; i < subscriptionList.length; i++) {
        var request = {
            "method": "DELETE",
            "url": "https://graph.microsoft.com/v1.0/subscriptions/" + subscriptionList[i],
            "headers": { "Accept": "application/json", "Authorization": "Bearer " + token }
        }
        success = success && doHTTP(request);
    }
    return success;
}

/*////////////////////////////////
//                              //
//             Utils            //
//                              //
////////////////////////////////*/
function doHTTP(method, url, body, headers, params)
{
    if (arguments.length === 1 && typeof arguments[0] === "object") {
        request = arguments[0];
        var method  = request.method;
        var url     = request.url;
        var body    = request.body || null;
        var headers = request.headers;
        headers["client-request-id"] = getGUID();
        request.params = request.params || {};
        var params  = request.params;
    } else { // Deprecated; het request is nu 1 object als argument
        var params = params || {};
    }

    // var SXH_PROXY_SET_PROXY = 2;
    var SXH_OPTION_IGNORE_SERVER_SSL_CERT_ERROR_FLAGS = 2;
    var SXH_SERVER_CERT_IGNORE_ALL_SERVER_ERRORS = 0x3300;

    var objXMLHTTP = new ActiveXObject("MSXML2.ServerXMLHTTP.6.0");

    objXMLHTTP.setTimeouts(1200000, 270000, 255000, 240000);
    // Defaults:         Unlimited, 60000,  30000,  30000

    objXMLHTTP.open(method, url, !!params.async);
    if (headers) {
        if ("Authorization" in headers && headers.Authorization === "Bearer null") {
            headers.Authorization = "Bearer " + requestToken(config);
        }
        var header;
        for (header in headers) {
            objXMLHTTP.setRequestHeader(header, headers[header]);
        }
    }
    var user_lang = "nl";
    if (typeof DEZE !== "undefined") { // Alleen vanuit de browser
        user_lang = (DEZE.Session("user_lang") || "nl").toLowerCase();
    }
    objXMLHTTP.setRequestHeader("Accept-Language", user_lang);
    objXMLHTTP.setRequestHeader("Prefer", "outlook.timezone=\"UTC\""); // Altijd UTC, zetten we later zelf wel om

    objXMLHTTP.setOption(SXH_OPTION_IGNORE_SERVER_SSL_CERT_ERROR_FLAGS, SXH_SERVER_CERT_IGNORE_ALL_SERVER_ERRORS);

    var start = new Date().getTime();
    try {
        objXMLHTTP.send(body);
    } catch (e) {
        var duration = new Date().getTime() - start;
        __DoLog("doHTTP ERROR: " + e.description + " (" + duration + "ms)", "#FF0000"); // Bijvoorbeeld; 'A connection with the server could not be established' als ngrok lokaal niet draait
        if (request) {
            __DoLog(JSON.stringify(request, null, 2));
        }
        return null;
    }

    return _handleMsGraphResponse(request, objXMLHTTP);
}

/* Evalueert een MS Graph response */
function _handleMsGraphResponse(request, httpResponse)
{
    if (request.params.async) {
        if (httpResponse.readyState && httpResponse.readyState != 4) {
            httpResponse.waitForResponse(180); // Wacht max. 180s op antwoord
        }
    }

    if (!request.batch &&   // Als request.batch = true, dan is httpResponse een geparsed object, en geen echt httpResponse
            !(httpResponse.readyState && httpResponse.readyState == 4) ||
        !(httpResponse.status && httpResponse.status >= 200 && httpResponse.status < 300)) {
        return _handleMsGraphFailure(request, httpResponse);
    } else if (request.method === "DELETE") { // 204 = 'No Content' dus niks terug te geven
        return true;
    }

    /* Parse het alvast, 'batch' is al een geparsed object, en geen httpResponse */
    if (request.batch) {
        httpResponse = httpResponse.body;
    } else { /* MS Graph returned altijd JSON */
        try {
            httpResponse = JSON.parse(httpResponse.responseText);
        } catch (e) { /* Ok, dan niet */ }
    }

    if (request.method === "GET") { // Detecteer skip/delta-tokens en zet die in het response-object
        if ("@odata.deltaLink" in httpResponse) {       // Dit is de deltatoken die we opslaan als syncstate; "Tot hier zijn we gesynced"
            httpResponse.deltatoken = httpResponse["@odata.deltaLink"].split("$deltatoken=")[1];
        } else if ("@odata.nextLink" in httpResponse) { // Er is meer; we roepen ons zelf straks nogmaals aan met dit token
            httpResponse.skiptoken  = httpResponse["@odata.nextLink"].split("$skiptoken=")[1];
        }
    }

    if (request.params.callback) {
        return request.params.callback(request, httpResponse);
    }

    return httpResponse;
}

// Onderstaande functie wordt alleen aangeroepen als de response niet aanwezig, of onsuccesvol is
function _handleMsGraphFailure(request, httpResponse) {
    if (httpResponse && (!httpResponse.readyState || httpResponse.readyState == 4) && httpResponse.status) {
        try {
            var msGraphResponse = httpResponse.responseText ? JSON.parse(httpResponse.responseText) : httpResponse.body; // Die laatste is voor batches
        } catch (e) {
            var msGraphResponse = httpResponse.responseText || "";
            __DoLog("Error parsing MS Graph response (" + e.description + ")", "#FF0000");
            __DoLog(msGraphResponse);
        }

        // Hier gaat iets fout
        if (msGraphResponse !== null && typeof msGraphResponse == "object" && typeof msGraphResponse.error == "object") { // MS Graph errors
            switch (msGraphResponse.error.code) {
                case "ErrorItemNotFound":
                    __Log("Reservering kon niet in Exchange gevonden worden", "#FFFF44");
                    break;
                case "ResyncRequired":
                case "SyncStateNotFound":
                    __DoLog("[" + httpResponse.status + "] - MS Graph [" + msGraphResponse.error.code + "] - " + msGraphResponse.error.message, "#FFFF44");
                    __Log("Refreshing syncstate ...");
                    var userPrincipalName = request.url.substring(
                        request.url.indexOf("/users/") + "/users/".length,
                        request.url.lastIndexOf("/calendarView/")
                    );
                    return getCalendarItems(decodeURIComponent(userPrincipalName), null, null); // delta token is gone, invalid of expired -> full sync
                case "ApplicationThrottled":
                    __DoLog("[" + httpResponse.status + "] - MS Graph [ApplicationThrottled] - " + msGraphResponse.error.message, "#FF0000");
            /* UNTESTED
                    var retryAfterXSeconds = parseInt(httpResponse.getResponseHeader("Retry-After"), 10);
                    __DoLog("volgens MS Graph moeten we het over " + retryAfterXSeconds + "s weer proberen", "#FFFF44");
                    if (!request || retryAfterXSeconds > 100) { // Dan niet hoor
                      return false;
                    }
                    var oSLNKDWF = new ActiveXObject("SLNKDWF.About");
                    oSLNKDWF.Sleep(retryAfterXSeconds * 1000);
                    return doHTTP(request);
            */
                    break;
                case "FailedDependency":
                    __DoLog("[" + httpResponse.status + "] - MS Graph [FailedDependency] - " + msGraphResponse.error.message + " => Retrying individually ..", "#FFFF44");
                    delete request.batch;
                    delete request.dependsOn;
                    delete request.id;
                    request.url = "https://graph.microsoft.com/v1.0" + request.url;
                    request.body = JSON.stringify(request.body);
                    return doHTTP(request); // Individueel nog een keer proberen dan maar
            // Vanaf hier lossen we de fouten niet zelf meer op
                case "ErrorInvalidUser": // Gebruiker niet gevonden
                case "ResourceNotFound": // Ruimte niet gevonden
                case "MailboxNotEnabledForRESTAPI": // Gebruiker is inactief/soft-deleted in Exchange
                default:
                    __DoLog("[" + httpResponse.status + "] - MS Graph [" + msGraphResponse.error.code + "] - " + msGraphResponse.error.message + " (request-id: " + (request.batch ? "[BATCHED REQUEST]" : httpResponse.getResponseHeader("request-id")) + ")", "#FF0000");
                    if (request) {
                        __DoLog(JSON.stringify(request, null, 2));
                    }
                    break;
            }
        } else if (msGraphResponse !== null && typeof msGraphResponse == "object" && typeof msGraphResponse.error == "string") { // Azure errors
            __DoLog("[" + httpResponse.status + "] - Azure error: [" + msGraphResponse.error + "]: " + msGraphResponse.error_description, "#FF0000");
        } else if (typeof msGraphResponse === "undefined") {
            __DoLog("MS Graph HTTP-request NIET succesvol afgehandeld, geen geldig antwoord ontvangen", "#FF0000");
            var err = "[" + (httpResponse.status || "HTTP STATUS UNDEFINED") + "] - MS Graph error";
            if (request && request.batch) { // httpResponse is een geparsed object, en geen echte httpResponse
                if (httpResponse.body) {
                    err += " - " + JSON.stringify(httpResponse.body); // Volgens mij kan dit niet voorkomen, want msGraphResponse is undefined
                } else if ("body" in httpResponse && !httpResponse.body) {
                    err += " - httpResponse.body is leeg";
                } else {
                    err += " - httpResponse.body is niet aanwezig";
                }
            } else {
                err += ": [readyState " + httpResponse.readyState + "] - statusText: " + httpResponse.statusText;
            }
            __DoLog(err);
            if (request) {
                __DoLog(JSON.stringify(request, null, 2));
            }
        } else {
            __DoLog("MS Graph HTTP-request NIET succesvol afgehandeld, onderstaand antwoord ontvangen", "#FF0000");
            __DoLog(msGraphResponse);
        }
    } else {
        __DoLog("Error: Geen response van MS Graph ontvangen (Configuratie/verbindings-problemen).", "#FF0000");
    }
    return false;
}

function getImportAppKey(appCode) {
    var result = "";
    var sql = "SELECT i.fac_import_app_key "
            + "  FROM fac_import_app i"
            + " WHERE i.fac_import_app_code = " + safe.quoted_sql(appCode);
    var oRs = Oracle.Execute(sql);
    if (oRs.EOF) {
        __DoLog("FATAL: Import '" + appCode + "' not found");
        return false;
    }
    result = oRs("fac_import_app_key").Value;
    oRs.Close();
    return result;
}

function doImport(res_ruimte_key, zaalemail, import_type) {
    if (typeof import_type === "undefined") {
        var import_type = "EXCHFULL";
    }
    var import_app_key = getImportAppKey(import_type);

    var fileStream = generateCalendarImport(zaalemail, import_app_key, true); // as Stream
    if (!fileStream || !fileStream.Size) {
        __Log("No action required after receiving MS Graph import", "#FFFF44");
        return true; // Klaar
    }

    if (typeof DEZE !== "undefined") { /* Alleen vanuit de asp, en niet bij het runnen van refreshMSGraphSubscriptions.bat */
        user_key = DEZE.user_key;
        customerId = DEZE.customerId;
    }
    var procedure_params = ", " + (config.fullpast || 0) + ", " + (config.fullfuture || 90);
    var res = impReadStream(fileStream,
                            import_app_key,
                            {   fac_home: custabspath + "/exchange/",
                                filepathname: "SYNC_" + zaalemail,
                                customerId: customerId,
                                ref_key: res_ruimte_key,
                                keep_old: 300, // Parallelle import 300 seconden ondersteunen (nodig voor deze import?)
                                user_key: user_key,
                                keep_backup: false, // mits fac_import_app_folder gezet
                                is_stream: true,
                                proc_params: procedure_params
                            });
    if (res.success) {
        var processres = impProcessStream(res.import_key, {
            customerId: customerId,
            proc_params: procedure_params
        });
    } else {
        __DoLog(res);
    }
    return true;
}

function doImportSingle(res_ruimte_key, zaalemail, decryptedResourceData) {
    var csvFileText = graphToImport([decryptedResourceData], zaalemail);
    if (!csvFileText) {
        __Log("No action required after receiving MS Graph import", "#FFFF44");
        return true; // Klaar
    }
    if (typeof DEZE !== "undefined") { /* Alleen vanuit de asp, en niet bij het runnen van process_webhooks.bat */
        user_key = DEZE.user_key;
        customerId = DEZE.customerId;
    }
    fileStream = new ActiveXObject("ADODB.Stream");
    fileStream.Type = 2; // adTypeBinary eerst nog
    fileStream.Open();
    fileStream.CharSet = "utf-8";
    fileStream.WriteText(csvFileText);
    var procedure_params = ", " + (config.fullpast || 0) + ", " + (config.fullfuture || 90);
    var res = impReadStream(fileStream,
                            getImportAppKey("EXCHANGE"),
                            { fac_home: custabspath + "/exchange/",
                              filepathname: "SYNC_" + zaalemail,
                              customerId: customerId,
                              ref_key: res_ruimte_key,
                              keep_old: 300, // Parallelle import 300 seconden ondersteunen (nodig voor deze import?)
                              user_key: user_key,
                              keep_backup: false, // mits fac_import_app_folder gezet
                              is_stream: true,
                              proc_params: procedure_params
                            });
    if (res.success) {
        var processres = impProcessStream(res.import_key, {
            customerId: customerId,
            proc_params: procedure_params
        });
    }
    return true;
}

if (!Date.prototype.toISOString) {
    Date.prototype.toISOString = function () {
        function pad(n) { return n < 10 ? '0' + n : n }
        return this.getUTCFullYear() + '-'
            + pad(this.getUTCMonth() + 1) + '-'
            + pad(this.getUTCDate()) + 'T'
            + pad(this.getUTCHours()) + ':'
            + pad(this.getUTCMinutes()) + ':'
            + pad(this.getUTCSeconds()) + 'Z';
    };
}

function getGUID() {
    // 12345678-1234-1234-1234-123456789012 lijkt het meest gebruikte format, laten wij dat ook maar aanhouden dan
    var guids = [];
    guids.push(oCrypto.hex_random(8));
    guids.push(oCrypto.hex_random(4));
    guids.push(oCrypto.hex_random(4));
    guids.push(oCrypto.hex_random(4));
    guids.push(oCrypto.hex_random(12));
    return guids.join("-");
}

function safefilename(naam) // geen 'lage' karakters en geen (back)slashes, *,%,<,>, '"', ':', ';' '?' and '|' of '+'
{
    return naam.replace(/[\x00-\x1F|\/|\\|\*|\%\<\>\"\:\;\?\|\+]+/g, "_"); // " syntax highlight correctie
}

// timeZone is hier altijd UTC, dus converteren naar server timeZone
function parseISOString(ISOString) {
    var s = ISOString.split(/\D+/); // Split op non-digits
    s[6] = Math.round(+s[6] / 10000); // Precisie van de milliseconden van 7 -> 3
    return new Date(Date.UTC(+s[0], --s[1], +s[2], +s[3], +s[4], +s[5], +s[6]));
}

function unlockChilkat() {
    try {
        var glob = new ActiveXObject("Chilkat_9_5_0.Global");
    } catch (e) {
        __DoLog(e.description, "#FF0000");
    }
    var sql = "SELECT COALESCE(fac_setting_pvalue, fac_setting_default) fac_setting_value"
            + "  FROM fac_setting"
            + " WHERE fac_setting_name = 'puo_chilkat_secret'";
    var oRs = Oracle.Execute(sql);
    var success = glob.UnlockBundle(oRs("fac_setting_value").Value);
    oRs.Close();
    if (success != 1) {
        __DoLog("Chilkat_9_5_0 Unlock failed:\n" + glob.LastErrorText, "#FF0000");
        return false;
    }
    return true;
}

function inArray(needle, haystack) {
    var length = haystack.length;
    for(var i = 0; i < length; i++) {
        if(haystack[i] == needle) return true;
    }
    return false;
}

String.prototype.format = function()
{
    var formatted = this;
    for (var i = 0; i < arguments.length; i++)
    {
        if (typeof arguments[i] == "string")
            arguments[i] = arguments[i].replace(/\$/g, '$$$$');
        var regexp = new RegExp('\\{'+i+'\\}', 'gi');
        formatted = formatted.replace(regexp, arguments[i]);
    }
    return formatted;
};