/****************************************************************************
 **
 ** Copyright (C) 2013-2014 Jolla Ltd.
 ** Contact: Lucien Xu <lucien.xu@jollamobile.com>
 **
 ** This program/library is free software; you can redistribute it and/or
 ** modify it under the terms of the GNU Lesser General Public License
 ** version 2.1 as published by the Free Software Foundation.
 **
 ** This program/library is distributed in the hope that it will be useful,
 ** but WITHOUT ANY WARRANTY; without even the implied warranty of
 ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 ** Lesser General Public License for more details.
 **
 ** You should have received a copy of the GNU Lesser General Public
 ** License along with this program/library; if not, write to the Free
 ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 ** 02110-1301 USA
 **
 ****************************************************************************/

#include "facebookcalendarsyncadaptor.h"
#include "trace.h"
#include "buteosyncfw_p.h"

#include <QtCore/QUrlQuery>
#include <QtCore/QFile>
#include <QtCore/QDir>
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QUuid>
#include <QtSql/QSqlQuery>
#include <QtSql/QSqlError>
#include <QtNetwork/QHttpMultiPart>
#include <QtNetwork/QHttpPart>

#include <extendedcalendar.h>
#include <extendedstorage.h>

#include <Accounts/Manager>
#include <Accounts/Account>

static const char *FACEBOOK = "Facebook";
static const char *FACEBOOK_COLOR = "#3B5998";

FacebookParsedEvent::FacebookParsedEvent()
    : m_isDateOnly(false)
    , m_endExists(false)
{
}

FacebookParsedEvent::FacebookParsedEvent(const FacebookParsedEvent &e)
{
    m_id = e.m_id;
    m_isDateOnly = e.m_isDateOnly;
    m_endExists = e.m_endExists;
    m_startTime = e.m_startTime;
    m_endTime = e.m_endTime;
    m_summary = e.m_summary;
    m_description = e.m_description;
}

namespace {
    // returns true if the ghost-event cleanup sync has been performed.
    bool ghostEventCleanupPerformed()
    {
        QString settingsFileName = QString::fromLatin1("%1/%2/fbcal.ini")
                .arg(PRIVILEGED_DATA_DIR)
                .arg(QString::fromLatin1(SYNC_DATABASE_DIR));
        QSettings settingsFile(settingsFileName, QSettings::IniFormat);
        return settingsFile.value(QString::fromLatin1("cleaned"), QVariant::fromValue<bool>(false)).toBool();
    }

    void setGhostEventCleanupPerformed()
    {
        QString settingsFileName = QString::fromLatin1("%1/%2/fbcal.ini")
                .arg(PRIVILEGED_DATA_DIR)
                .arg(QString::fromLatin1(SYNC_DATABASE_DIR));
        QSettings settingsFile(settingsFileName, QSettings::IniFormat);
        settingsFile.setValue(QString::fromLatin1("cleaned"), QVariant::fromValue<bool>(true));
        settingsFile.sync();
    }
}

FacebookCalendarSyncAdaptor::FacebookCalendarSyncAdaptor(QObject *parent)
    : FacebookDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::Calendars, parent)
    , m_calendar(mKCal::ExtendedCalendar::Ptr(new mKCal::ExtendedCalendar(QTimeZone::utc())))
    , m_storage(mKCal::ExtendedCalendar::defaultStorage(m_calendar))
    , m_storageNeedsSave(false)
{
    setInitialActive(true);
}

FacebookCalendarSyncAdaptor::~FacebookCalendarSyncAdaptor()
{
}

QString FacebookCalendarSyncAdaptor::syncServiceName() const
{
    return QStringLiteral("facebook-calendars");
}

void FacebookCalendarSyncAdaptor::sync(const QString &dataTypeString, int accountId)
{
    m_storageNeedsSave = false;
    m_parsedEvents.clear();
    m_storage->open(); // we close it in finalCleanup()
    FacebookDataTypeSyncAdaptor::sync(dataTypeString, accountId);
}

void FacebookCalendarSyncAdaptor::finalCleanup()
{
    if (syncAborted()) {
        qCInfo(lcSocialPlugin) << "sync aborted, won't commit database changes";
        m_storage->close();
        return;
    }

    // commit changes to db
    if (m_storageNeedsSave) {
        // apply changes from sync
        m_storage->save();

        // set the facebook notebook back to read-only
        Q_FOREACH (mKCal::Notebook::Ptr notebook, m_storage->notebooks()) {
            if (notebook->pluginName() == QLatin1String(FACEBOOK)
                && !notebook->isReadOnly()) {
                notebook->setIsReadOnly(true);
                m_storage->updateNotebook(notebook);
                break;
            }
        }

        // done.
        m_storageNeedsSave = false;
    }

    if (!ghostEventCleanupPerformed()) {
        // Delete any events which are not associated with a notebook.
        // These events are ghost events, caused by a bug which previously
        // existed in the purgeDataForOldAccount code.
        // The mkcal API doesn't allow us to determine which notebook a
        // given incidence belongs to, so we have to instead load
        // everything and then find the ones which are ophaned.
        m_storage->load();
        KCalendarCore::Incidence::List allIncidences = m_calendar->incidences();
        mKCal::Notebook::List allNotebooks = m_storage->notebooks();
        QSet<QString> notebookIncidenceUids;
        foreach (mKCal::Notebook::Ptr notebook, allNotebooks) {
            KCalendarCore::Incidence::List currNbIncidences;
            m_storage->allIncidences(&currNbIncidences, notebook->uid());
            foreach (KCalendarCore::Incidence::Ptr incidence, currNbIncidences) {
                notebookIncidenceUids.insert(incidence->uid());
            }
        }
        foreach (const KCalendarCore::Incidence::Ptr incidence, allIncidences) {
            if (!notebookIncidenceUids.contains(incidence->uid())) {
                // orphan/ghost incidence.  must be deleted.
                qCDebug(lcSocialPlugin) << "deleting orphan event with uid:" << incidence->uid();
                m_calendar->deleteIncidence(incidence);
                m_storageNeedsSave = true;
            }
        }
        if (!m_storageNeedsSave || m_storage->save()) {
            setGhostEventCleanupPerformed();
        }
    }

    // done.
    m_storage->close();
}

void FacebookCalendarSyncAdaptor::purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode mode)
{
    if (mode == SocialNetworkSyncAdaptor::CleanUpPurge) {
        // we need to initialise the storage
        m_storageNeedsSave = false;
        m_storage->open(); // we close it in finalCleanup()
    }

    // We clean all the entries in the calendar
    foreach (mKCal::Notebook::Ptr notebook, m_storage->notebooks()) {
        if (notebook->pluginName() == QLatin1String(FACEBOOK)
                && notebook->account() == QString::number(oldId)) {
            m_storage->deleteNotebook(notebook);
        }
    }

    if (mode == SocialNetworkSyncAdaptor::CleanUpPurge) {
        // and commit any changes made.
        finalCleanup();
    }
}

void FacebookCalendarSyncAdaptor::beginSync(int accountId, const QString &accessToken)
{
    qCDebug(lcSocialPlugin) << "beginning Calendar sync for Facebook account" << accountId;
    requestEvents(accountId, accessToken);
}

void FacebookCalendarSyncAdaptor::requestEvents(int accountId,
                                                const QString &accessToken,
                                                const QString &batchRequest)
{

    QString batch = batchRequest;
    if (batch.isEmpty()) {
        // Create batch query of following format:
        //    [{ "method":"GET","relative_url":"me/events?type=created&include_headers=false&limit=200&fields=..."},
        //     { "method":"GET","relative_url":"me/events?type=attending&include_headers=false&limit=200&fields=..."},
        //     { "method":"GET","relative_url":"me/events?type=maybe&include_headers=false&limit=200&fields=..."},
        //     { "method":"GET","relative_url":"me/events?type=not_replied&include_headers=false&limit=200&fields=..."}]

        int sinceSpan = m_accountSyncProfile
                ? m_accountSyncProfile->key(Buteo::KEY_SYNC_SINCE_DAYS_PAST, QStringLiteral("30")).toInt()
                : 30;
        uint startTime = QDateTime::currentDateTimeUtc().addDays(sinceSpan * -1).toTime_t();
        QString since = QStringLiteral("since=") + QString::number(startTime);
        QString calendarQuery = QStringLiteral("{\"method\":\"GET\",\"relative_url\":\"me/events?type=%1&include_headers=false&limit=200&fields=id,name,start_time,end_time,description,place&")
                                  + since
                                  + QStringLiteral("\"}");

        batch = QStringLiteral("[")
              + calendarQuery.arg(QStringLiteral("created")) + QStringLiteral(",")
              + calendarQuery.arg(QStringLiteral("attending")) + QStringLiteral(",")
              + calendarQuery.arg(QStringLiteral("maybe")) + QStringLiteral(",")
              + calendarQuery.arg(QStringLiteral("not_replied"))
              + QStringLiteral("]");
    }

    QUrl url(graphAPI());
    QNetworkRequest request(url);

    QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
    multiPart->setBoundary("-------Sska2129ifcalksmqq3");

    QHttpPart accessTokenPart;
    accessTokenPart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"access_token\""));
    accessTokenPart.setBody(accessToken.toUtf8());

    QHttpPart batchPart;
    batchPart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"batch\""));
    batchPart.setBody(batch.toUtf8());

    multiPart->append(accessTokenPart);
    multiPart->append(batchPart);
    request.setRawHeader("Content-Type", "multipart/form-data; boundary="+multiPart->boundary());
    QNetworkReply *reply = m_networkAccessManager->post(request, multiPart);
    if (reply) {
        multiPart->setParent(reply);
        reply->setProperty("accountId", accountId);
        reply->setProperty("accessToken", accessToken);
        connect(reply, SIGNAL(error(QNetworkReply::NetworkError)),
                this, SLOT(errorHandler(QNetworkReply::NetworkError)));
        connect(reply, SIGNAL(sslErrors(QList<QSslError>)),
                this, SLOT(sslErrorsHandler(QList<QSslError>)));
        connect(reply, SIGNAL(finished()), this, SLOT(finishedHandler()));

        if (batchRequest.isEmpty()) {
            // we're requesting data.  Increment the semaphore so that we know we're still busy.
            incrementSemaphore(accountId);
        }
        setupReplyTimeout(accountId, reply);
    } else {
        delete multiPart;
        qCWarning(lcSocialPlugin) << "unable to request events from Facebook account" << accountId;
    }
}

void FacebookCalendarSyncAdaptor::finishedHandler()
{
    QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
    int accountId = reply->property("accountId").toInt();
    QString accessToken = reply->property("accessToken").toString();
    QByteArray replyData = reply->readAll();
    bool isError = reply->property("isError").toBool();

    disconnect(reply);
    reply->deleteLater();
    removeReplyTimeout(accountId, reply);

    qCDebug(lcSocialPluginTrace) << "request finished, got response:";
    Q_FOREACH (const QString &line, QString::fromUtf8(replyData).split('\n', QString::SkipEmptyParts)) {
        qCDebug(lcSocialPluginTrace) << line;
    }

    QStringList ongoingRequests;
    QJsonArray array;
    bool ok = false;
    QJsonDocument jsonDocument = QJsonDocument::fromJson(replyData);
    if (!jsonDocument.isEmpty() && jsonDocument.isArray()) {
        array = jsonDocument.array();
        if (array.count() > 0) {
            ok = true;
        }
    }

    if (!isError && ok) {
        foreach (QJsonValue value, array) {
            // Go through each entry in batch reply and process the events it contains
            if (!value.isObject()) {
                qCWarning(lcSocialPlugin) << "Facebook calendar batch reply entry is not an object for account " << accountId;
                continue;
            }

            QJsonObject entry = value.toObject();
            if (entry.value(QLatin1String("code")).toInt() != 200) {
                qCWarning(lcSocialPlugin) << "Facebook calendar batch request for account "
                                  << accountId << " failed with " << entry.value("code").toInt();
                continue;
            }

            if (!entry.contains(QLatin1String("body"))) {
                qCWarning(lcSocialPlugin) << "Facebook calendar batch reply entry doesn't contain body field for account " << accountId;
                continue;
            }

            QJsonDocument bodyDocument = QJsonDocument::fromJson(entry.value(QLatin1String("body")).toString().toUtf8());
            if (bodyDocument.isEmpty()) {
                qCWarning(lcSocialPlugin) << "Facebook calendar batch reply body is empty for account " << accountId;
                continue;
            }

            QJsonObject parsed = bodyDocument.object();
            if (!parsed.contains(QLatin1String("data"))) {
                qCWarning(lcSocialPlugin) << "Facebook calendar batch reply entry doesn't contain data for account " << accountId;
                continue;
            }

            if (parsed.contains(QLatin1String("paging"))) {
                QJsonObject paging = parsed.value(QLatin1String("paging")).toObject();
                if (paging.contains(QLatin1String("next"))) {
                    QString nextQuery = paging.value(QLatin1String("next")).toString();
                    ongoingRequests.append(nextQuery);
                }
            }

            // Parse the event list
            QJsonArray dataList = parsed.value(QLatin1String("data")).toArray();
            foreach (QJsonValue data, dataList) {
                QJsonObject dataMap = data.toObject();
                QString eventId = dataMap.value(QLatin1String("id")).toVariant().toString();

                if (m_parsedEvents.contains(eventId)) {
                    // event was already handled by this batch request
                    continue;
                }

                FacebookParsedEvent parsedEvent;
                parsedEvent.m_id = eventId;

                QString startTimeString = dataMap.value(QLatin1String("start_time")).toString();
                QString endTimeString = dataMap.value(QLatin1String("end_time")).toString();
                if (endTimeString.isEmpty()) {
                    // workaround for empty ET events
                    endTimeString = startTimeString;
                }

                QDateTime parsedStartTime = QDateTime::fromString(startTimeString, Qt::ISODate);
                QDateTime parsedEndTime = QDateTime::fromString(endTimeString, Qt::ISODate);

                parsedEvent.m_startTime = parsedStartTime.toTimeZone(QTimeZone::systemTimeZone());
                parsedEvent.m_endTime = parsedEndTime.toTimeZone(QTimeZone::systemTimeZone());
                parsedEvent.m_summary = dataMap.value(QLatin1String("name")).toString();
                parsedEvent.m_description = dataMap.value(QLatin1String("description")).toString();
                parsedEvent.m_location = dataMap.value(QLatin1String("place")).toObject().toVariantMap().value("name").toString();
                m_parsedEvents[eventId] = parsedEvent;
            }
        }

        if (ongoingRequests.count() > 0) {
            // Form next batch request for still ongoing requests
            QString nextBatch("[");
            foreach (const QString next, ongoingRequests) {
                QUrl nextUrl(next);
                nextBatch.append(QStringLiteral("{\"method\":\"GET\",\"relative_url\":\"me/events?include_headers=false&"));
                nextBatch.append(nextUrl.query());
                nextBatch.append(QStringLiteral("\"},"));
            }
            nextBatch.chop(1);      // remove last comma
            nextBatch.append(QStringLiteral("]"));
            requestEvents(accountId, accessToken, nextBatch);
        } else {
            qCDebug(lcSocialPlugin) << "finished all requests, about to perform database update";
            processParsedEvents(accountId);
            decrementSemaphore(accountId);
        }
    } else {
        // Error occurred during request.
        qCWarning(lcSocialPlugin) << "unable to parse calendar data from request with account"
                          << accountId << ", got:" << QString::fromLatin1(replyData.constData());
        decrementSemaphore(accountId);
    }
}


void FacebookCalendarSyncAdaptor::processParsedEvents(int accountId)
{
    // Search for the Facebook Notebook
    qCDebug(lcSocialPlugin) << "Received" << m_parsedEvents.size() << "events from server; determining delta";
    mKCal::Notebook::Ptr fbNotebook;
    Q_FOREACH (mKCal::Notebook::Ptr notebook, m_storage->notebooks()) {
        if (notebook->pluginName() == QLatin1String(FACEBOOK) && notebook->account() == QString::number(accountId)) {
            fbNotebook = notebook;
        }
    }

    if (!fbNotebook) {
        // create the notebook if required
        fbNotebook = mKCal::Notebook::Ptr(new mKCal::Notebook);
        fbNotebook->setName(QLatin1String(FACEBOOK));
        fbNotebook->setPluginName(QLatin1String(FACEBOOK));
        fbNotebook->setAccount(QString::number(accountId));
        fbNotebook->setColor(QLatin1String(FACEBOOK_COLOR));
        fbNotebook->setDescription(m_accountManager->account(accountId)->displayName());
        fbNotebook->setIsReadOnly(true);
        m_storage->addNotebook(fbNotebook);
    } else {
        // update the notebook details if required
        bool changed = false;
        if (fbNotebook->description().isEmpty()) {
            fbNotebook->setDescription(m_accountManager->account(accountId)->displayName());
            changed = true;
        }

        if (changed) {
            m_storage->updateNotebook(fbNotebook);
        }
    }

    // We load incidences that are associated to Facebook into memory
    KCalendarCore::Incidence::List dbEvents;
    m_storage->loadNotebookIncidences(fbNotebook->uid());
    if (!m_storage->allIncidences(&dbEvents, fbNotebook->uid())) {
        qCWarning(lcSocialPlugin) << "unable to load Facebook events from database";
        return;
    }

    // Now determine the delta to the events received from server.
    QSet<QString> seenLocalEvents;
    Q_FOREACH (const QString &fbId, m_parsedEvents.keys()) {
        // find the local event associated with this event.
        bool foundLocal = false;
        const FacebookParsedEvent &parsedEvent = m_parsedEvents[fbId];
        Q_FOREACH (KCalendarCore::Incidence::Ptr incidence, dbEvents) {
            if (incidence->uid().endsWith(QStringLiteral(":%1").arg(fbId))) {
                KCalendarCore::Event::Ptr event = m_calendar->event(incidence->uid());
                if (!event) continue; // not a valid event incidence.
                // found. If it has been modified remotely, then modify locally.
                foundLocal = true;
                seenLocalEvents.insert(incidence->uid());
                if (event->summary() != parsedEvent.m_summary ||
                    event->description() != parsedEvent.m_description ||
                    event->location() != parsedEvent.m_location ||
                    event->dtStart() != parsedEvent.m_startTime ||
                    (parsedEvent.m_endExists && event->dtEnd() != parsedEvent.m_endTime)) {
                    // the event has been changed remotely.
                    event->startUpdates();
                    event->setSummary(parsedEvent.m_summary);
                    event->setDescription(parsedEvent.m_description);
                    event->setLocation(parsedEvent.m_location);
                    event->setDtStart(parsedEvent.m_startTime);
                    if (parsedEvent.m_endExists) {
                        event->setDtEnd(parsedEvent.m_endTime);
                    }
                    if (parsedEvent.m_isDateOnly) {
                        event->setAllDay(true);
                    }
                    event->setReadOnly(true);
                    event->endUpdates();
                    m_storageNeedsSave = true;
                    qCDebug(lcSocialPlugin) << "Facebook event" << event->uid() << "was modified on server";
                } else {
                    qCDebug(lcSocialPlugin) << "Facebook event" << event->uid() << "is unchanged on server";
                }
            }
        }

        // if not found locally, it must be a new addition.
        if (!foundLocal) {
            KCalendarCore::Event::Ptr event = KCalendarCore::Event::Ptr(new KCalendarCore::Event);
            QString eventUid = QUuid::createUuid().toString();
            eventUid = eventUid.mid(1); // remove leading {
            eventUid.chop(1);           // remove trailing }
            eventUid = QStringLiteral("%1:%2").arg(eventUid).arg(fbId);
            event->setUid(eventUid);
            event->setSummary(parsedEvent.m_summary);
            event->setDescription(parsedEvent.m_description);
            event->setLocation(parsedEvent.m_location);
            event->setDtStart(parsedEvent.m_startTime);
            if (parsedEvent.m_endExists) {
                event->setDtEnd(parsedEvent.m_endTime);
            }
            if (parsedEvent.m_isDateOnly) {
                event->setAllDay(true);
            }
            event->setReadOnly(true);
            m_calendar->addEvent(event, fbNotebook->uid());
            m_storageNeedsSave = true;
            qCDebug(lcSocialPlugin) << "Facebook event" << event->uid() << "was added on server";
        }
    }

    // Any local events which were not seen, must have been removed remotely.
    Q_FOREACH (KCalendarCore::Incidence::Ptr incidence, dbEvents) {
        if (!seenLocalEvents.contains(incidence->uid())) {
            // note: have to delete from calendar after loaded from calendar.
            m_calendar->deleteIncidence(m_calendar->incidence(incidence->uid()));
            m_storageNeedsSave = true;
            qCDebug(lcSocialPlugin) << "Facebook event" << incidence->uid() << "was deleted on server";
        }
    }
}
