/*
 *  kalarmdirresource.cpp  -  Akonadi directory resource for KAlarm
 *  Program:  kalarm
 *  Copyright © 2011-2019 David Jarvie <djarvie@kde.org>
 *  Copyright (c) 2008 Tobias Koenig <tokoe@kde.org>
 *  Copyright (c) 2008 Bertjan Broeksema <broeksema@kde.org>
 *
 *  This library is free software; you can redistribute it and/or modify it
 *  under the terms of the GNU Library General Public License as published by
 *  the Free Software Foundation; either version 2 of the License, or (at your
 *  option) any later version.
 *
 *  This 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 Library General Public
 *  License for more details.
 *
 *  You should have received a copy of the GNU Library General Public License
 *  along with this library; see the file COPYING.LIB.  If not, write to the
 *  Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 *  02110-1301, USA.
 */

#include "kalarmdirresource.h"
#include "kalarmresourcecommon.h"
#include "autoqpointer.h"
#include "kalarmdirsettingsadaptor.h"
#include "settingsdialog.h"

#include <kalarmcal/akonadi.h>
#include <kalarmcal/kacalendar.h>

#include <KCalendarCore/FileStorage>
#include <KCalendarCore/ICalFormat>
#include <KCalendarCore/MemoryCalendar>
#include <changerecorder.h>
#include <QDBusConnection>
#include <entitydisplayattribute.h>
#include <collectionfetchjob.h>
#include <collectionfetchscope.h>
#include <collectionmodifyjob.h>
#include <itemfetchscope.h>
#include <itemcreatejob.h>
#include <itemdeletejob.h>
#include <itemmodifyjob.h>

#include <KDirWatch>
#include <KLocalizedString>

#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QTimer>
#include "kalarmdirresource_debug.h"

using namespace Akonadi;
using namespace KCalendarCore;
using namespace Akonadi_KAlarm_Dir_Resource;
using KAlarmResourceCommon::errorMessage;

static const char warningFile[] = "WARNING_README.txt";

#define DEBUG_DATA(func) \
    qCDebug(KALARMDIRRESOURCE_LOG)<<func<<"ID:Files:"; \
    foreach (const QString &id, mEvents.keys()) { qCDebug(KALARMDIRRESOURCE_LOG)<<id<<":"<<mEvents[id].files; } \
    qCDebug(KALARMDIRRESOURCE_LOG)<<"File:IDs:"; \
    foreach (const QString &f, mFileEventIds.keys()) { qCDebug(KALARMDIRRESOURCE_LOG)<<f<<":"<<mFileEventIds[f]; }

KAlarmDirResource::KAlarmDirResource(const QString &id)
    : ResourceBase(id)
    , mSettings(new Settings(config()))
    , mCollectionId(-1)
    , mCompatibility(KACalendar::Incompatible)
    , mCollectionFetched(false)
    , mWaitingToRetrieve(false)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "Starting:" << id;
    KAlarmResourceCommon::initialise(this);

    // Set up the resource
    new KAlarmDirSettingsAdaptor(mSettings);
    QDBusConnection::sessionBus().registerObject(QStringLiteral("/Settings"),
                                                 mSettings, QDBusConnection::ExportAdaptors);
    connect(mSettings, &Akonadi_KAlarm_Dir_Resource::Settings::configChanged, this, &KAlarmDirResource::settingsChanged);

    changeRecorder()->itemFetchScope().fetchFullPayload();
    changeRecorder()->fetchCollection(true);

    connect(KDirWatch::self(), &KDirWatch::created, this, &KAlarmDirResource::fileCreated);
    connect(KDirWatch::self(), &KDirWatch::dirty, this, &KAlarmDirResource::fileChanged);
    connect(KDirWatch::self(), &KDirWatch::deleted, this, &KAlarmDirResource::fileDeleted);

    // Find the collection which this resource manages
    CollectionFetchJob *job = new CollectionFetchJob(Collection::root(), CollectionFetchJob::FirstLevel);
    job->fetchScope().setResource(identifier());
    connect(job, &CollectionFetchJob::result, this, &KAlarmDirResource::collectionFetchResult);

    QTimer::singleShot(0, this, [this] {
        loadFiles();
    });
}

KAlarmDirResource::~KAlarmDirResource()
{
    delete mSettings;
}

void KAlarmDirResource::aboutToQuit()
{
    mSettings->save();
}

/******************************************************************************
* Called when the collection fetch job completes.
* Check the calendar files' compatibility statuses if pending.
*/
void KAlarmDirResource::collectionFetchResult(KJob *j)
{
    qCDebug(KALARMDIRRESOURCE_LOG);
    if (j->error()) {
        qCritical() << "Error: collectionFetchResult: " << j->errorString();
    } else {
        CollectionFetchJob *job = static_cast<CollectionFetchJob *>(j);
        Collection::List collections = job->collections();
        int count = collections.count();
        qCDebug(KALARMDIRRESOURCE_LOG) << "collectionFetchResult: count:" << count;
        if (!count) {
            qCritical() << "Cannot retrieve this resource's collection";
        } else {
            if (count > 1) {
                qCritical() << "Multiple collections for this resource:" << count;
            }
            Collection &c(collections[0]);
            qCDebug(KALARMDIRRESOURCE_LOG) << "collectionFetchResult: id:" << c.id() << ", remote id:" << c.remoteId();
            if (!mCollectionFetched) {
                bool recreate = mSettings->path().isEmpty();
                if (!recreate) {
                    // Remote ID could be path or URL, depending on which version
                    // of Akonadi created it.
                    const QString rid = c.remoteId();
                    const QUrl url = QUrl::fromLocalFile(mSettings->path());
                    if (!url.isLocalFile()
                        || (rid != url.toLocalFile() && rid != url.url() && rid != url.toDisplayString())) {
                        qCritical() << "Collection remote ID does not match settings: changing settings";
                        recreate = true;
                    }
                }
                if (recreate) {
                    // Initialising a resource which seems to have no stored
                    // settings config file. Recreate the settings.
                    static const Collection::Rights writableRights = Collection::CanChangeItem | Collection::CanCreateItem | Collection::CanDeleteItem;
                    qCDebug(KALARMDIRRESOURCE_LOG) << "collectionFetchResult: Recreating config for remote id:" << c.remoteId();
                    mSettings->setPath(c.remoteId());
                    mSettings->setDisplayName(c.name());
                    mSettings->setAlarmTypes(c.contentMimeTypes());
                    mSettings->setReadOnly((c.rights() & writableRights) != writableRights);
                    mSettings->save();
                }
                mCollectionId = c.id();
                if (recreate) {
                    // Load items from the backend files now that their location is known
                    loadFiles(true);
                }

                // Set collection's format compatibility flag now that the collection
                // and its attributes have been fetched.
                KAlarmResourceCommon::setCollectionCompatibility(c, mCompatibility, mVersion);
            }
        }
    }
    mCollectionFetched = true;
    if (mWaitingToRetrieve) {
        mWaitingToRetrieve = false;
        retrieveCollections();
    }
}

/******************************************************************************
*/
void KAlarmDirResource::configure(WId windowId)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "configure";
    // Keep note of the old configuration settings
    QString path = mSettings->path();
    QString name = mSettings->displayName();
    bool readOnly = mSettings->readOnly();
    QStringList types = mSettings->alarmTypes();
    // Note: mSettings->monitorFiles() can't change here

    // Use AutoQPointer to guard against crash on application exit while
    // the dialogue is still open. It prevents double deletion (both on
    // deletion of parent, and on return from this function).
    AutoQPointer<SettingsDialog> dlg = new SettingsDialog(windowId, mSettings);
    if (dlg->exec()) {
        if (path.isEmpty()) {
            // Creating a new resource
            clearCache();   // this deletes any existing collection
            loadFiles(true);
            synchronizeCollectionTree();
        } else if (mSettings->path() != path) {
            // Directory path change is not allowed for existing resources
            Q_EMIT configurationDialogRejected();
            return;
        } else {
            bool modify = false;
            Collection c(mCollectionId);
            if (mSettings->alarmTypes() != types) {
                // Settings have changed which might affect the alarm configuration
                initializeDirectory();   // should only be needed for new resource, but just in case ...
                CalEvent::Types newTypes = CalEvent::types(mSettings->alarmTypes());
                CalEvent::Types oldTypes = CalEvent::types(types);
                changeAlarmTypes(~newTypes & oldTypes);
                c.setContentMimeTypes(mSettings->alarmTypes());
                modify = true;
            }
            if (mSettings->readOnly() != readOnly
                || mSettings->displayName() != name) {
                // Need to change the collection's rights or name
                c.setRemoteId(directoryName());
                setNameRights(c);
                modify = true;
            }
            if (modify) {
                // Update the Akonadi server with the changes
                CollectionModifyJob *job = new CollectionModifyJob(c);
                connect(job, &CollectionFetchJob::result, this, &KAlarmDirResource::jobDone);
            }
        }
        Q_EMIT configurationDialogAccepted();
    } else {
        Q_EMIT configurationDialogRejected();
    }
}

/******************************************************************************
* Add/remove events to ensure that they match the changed alarm types for the
* resource.
*/
void KAlarmDirResource::changeAlarmTypes(CalEvent::Types removed)
{
    DEBUG_DATA("changeAlarmTypes:");
    const QString dirPath = directoryName();
    qCDebug(KALARMDIRRESOURCE_LOG) << "changeAlarmTypes:" << dirPath;
    const QDir dir(dirPath);

    // Read and parse each file in turn
    QDirIterator it(dir);
    while (it.hasNext()) {
        it.next();
        int removeIfInvalid = 0;
        QString fileEventId;
        const QString file = it.fileName();
        if (!isFileValid(file)) {
            continue;
        }
        QHash<QString, QString>::iterator fit = mFileEventIds.find(file);
        if (fit != mFileEventIds.end()) {
            // The file is in the existing file list
            fileEventId = fit.value();
            QHash<QString, EventFile>::ConstIterator it = mEvents.constFind(fileEventId);
            if (it != mEvents.constEnd()) {
                // And its event is in the existing events list
                const EventFile &data = it.value();
                if (data.files[0] == file) {
                    // It's the file for a used event
                    if (data.event.category() & removed) {
                        // The event's type is no longer wanted, so remove it
                        deleteItem(data.event);
                        removeEvent(data.event.id(), false);
                    }
                    continue;
                } else {
                    // The file's event is not currently used - load the
                    // file and use its event if appropriate.
                    removeIfInvalid = 0x03;   // remove from mEvents and mFileEventIds
                }
            } else {
                // The file's event isn't in the list of current valid
                // events - this shouldn't ever happen
                removeIfInvalid = 0x01;   // remove from mFileEventIds
            }
        }

        // Load the file and use its event if appropriate.
        const QString path = filePath(file);
        if (QFileInfo(path).isFile()) {
            if (createItemAndIndex(path, file)) {
                continue;
            }
        }
        // The event wasn't wanted, so remove from lists
        if (removeIfInvalid & 0x01) {
            mFileEventIds.erase(fit);
        }
        if (removeIfInvalid & 0x02) {
            removeEventFile(fileEventId, file);
        }
    }
    DEBUG_DATA("changeAlarmTypes exit:");
    setCompatibility();
}

/******************************************************************************
* Called when the resource settings have changed.
* Update the display name if it has changed.
* Stop monitoring the directory if 'monitorFiles' is now false.
* Update the storage format if UpdateStorageFormat setting = true.
* NOTE: no provision is made for changes to the directory path, since this is
*       not permitted (would need remote ID changed, plus other complications).
*/
void KAlarmDirResource::settingsChanged()
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "settingsChanged";
    const QString display = mSettings->displayName();
    if (display != name()) {
        setName(display);
    }

    const QString dirPath = mSettings->path();
    if (!dirPath.isEmpty()) {
        const bool monitoring = KDirWatch::self()->contains(dirPath);
        if (monitoring && !mSettings->monitorFiles()) {
            KDirWatch::self()->removeDir(dirPath);
        } else if (!monitoring && mSettings->monitorFiles()) {
            KDirWatch::self()->addDir(dirPath, KDirWatch::WatchFiles);
        }
#if 0
        if (mSettings->monitorFiles() && !monitor) {
            // Settings have changed which might affect the alarm configuration
            qCDebug(KALARMDIRRESOURCE_LOG) << "Monitored changed";
            loadFiles(true);
//              synchronizeCollectionTree();
        }
#endif
    }

    if (mSettings->updateStorageFormat()) {
        // This is a flag to request that the backend calendar storage format should
        // be updated to the current KAlarm format.
        KACalendar::Compat okCompat(KACalendar::Current | KACalendar::Convertible);
        if (mCompatibility & ~okCompat) {
            qCWarning(KALARMDIRRESOURCE_LOG) << "Either incompatible storage format or nothing to update";
        } else if (mSettings->readOnly()) {
            qCWarning(KALARMDIRRESOURCE_LOG) << "Cannot update storage format for a read-only resource";
        } else {
            // Update the backend storage format to the current KAlarm format
            bool ok = true;
            for (QHash<QString, EventFile>::iterator it = mEvents.begin(); it != mEvents.end(); ++it) {
                KAEvent &event = it.value().event;
                if (event.compatibility() == KACalendar::Convertible) {
                    if (writeToFile(event)) {
                        event.setCompatibility(KACalendar::Current);
                    } else {
                        qCWarning(KALARMDIRRESOURCE_LOG) << "Error updating storage format for event id" << event.id();
                        ok = false;
                    }
                }
            }
            if (ok) {
                mCompatibility = KACalendar::Current;
                mVersion = KACalendar::CurrentFormat;
                const Collection c(mCollectionId);
                if (c.isValid()) {
                    KAlarmResourceCommon::setCollectionCompatibility(c, mCompatibility, mVersion);
                }
            }
        }
        mSettings->setUpdateStorageFormat(false);
        mSettings->save();
    }
}

/******************************************************************************
* Load and parse data from each file in the directory.
* The events are cached in mEvents.
*/
bool KAlarmDirResource::loadFiles(bool sync)
{
    const QString dirPath = directoryName();
    if (dirPath.isEmpty()) {
        return false;
    }
    qCDebug(KALARMDIRRESOURCE_LOG) << "loadFiles:" << dirPath;
    const QDir dir(dirPath);

    // Create the directory if it doesn't exist.
    // This should only be needed for a new resource, but just in case ...
    initializeDirectory();

    mEvents.clear();
    mFileEventIds.clear();

    // Set the resource display name to the configured name, else the directory
    // name, if not already set.
    QString display = mSettings->displayName();
    if (display.isEmpty() && (name().isEmpty() || name() == identifier())) {
        display = dir.dirName();
    }
    if (!display.isEmpty()) {
        setName(display);
    }

    // Read and parse each file in turn
    QDirIterator it(dir);
    while (it.hasNext()) {
        it.next();
        const QString file = it.fileName();
        if (isFileValid(file)) {
            const QString path = filePath(file);
            if (QFileInfo(path).isFile()) {
                const KAEvent event = loadFile(path, file);
                if (event.isValid()) {
                    addEventFile(event, file);
                    mFileEventIds.insert(file, event.id());
                }
            }
        }
    }
    DEBUG_DATA("loadFiles:");

    setCompatibility(false);   // don't write compatibility - no collection exists yet

    if (mSettings->monitorFiles()) {
        // Monitor the directory for changes to the files
        if (!KDirWatch::self()->contains(dirPath)) {
            KDirWatch::self()->addDir(dirPath, KDirWatch::WatchFiles);
        }
    }

    if (sync) {
        // Ensure the Akonadi server is updated with the current list of events
        synchronize();
    }

    Q_EMIT status(Idle);
    return true;
}

/******************************************************************************
* Load and parse data a single file in the directory.
* 'path' is the full path of 'file'.
* 'file' should not contain any directory component.
*/
KAEvent KAlarmDirResource::loadFile(const QString &path, const QString &file)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "loadFile:" << path;
    MemoryCalendar::Ptr calendar(new MemoryCalendar(QTimeZone::utc()));
    FileStorage::Ptr fileStorage(new FileStorage(calendar, path, new ICalFormat()));
    if (!fileStorage->load()) {
        // Don't output an error in the case of the creation of a temporary
        // file which triggered fileChanged() but no longer exists.
        if (QFileInfo::exists(path)) {
            qCWarning(KALARMDIRRESOURCE_LOG) << "Error loading" << path;
        }
        return KAEvent();
    }
    const Event::List events = calendar->events();
    if (events.isEmpty()) {
        qCDebug(KALARMDIRRESOURCE_LOG) << "Empty calendar in file" << path;
        return KAEvent();
    }
    if (events.count() > 1) {
        qCWarning(KALARMDIRRESOURCE_LOG) << "Deleting" << events.count() - 1 << "excess events found in file" << path;
        for (int i = 1; i < events.count(); ++i) {
            calendar->deleteEvent(events[i]);
        }
    }
    const Event::Ptr kcalEvent(events[0]);
    if (kcalEvent->uid() != file) {
        qCWarning(KALARMDIRRESOURCE_LOG) << "File" << path << ": event id differs from file name";
    }
    if (kcalEvent->alarms().isEmpty()) {
        qCWarning(KALARMDIRRESOURCE_LOG) << "File" << path << ": event contains no alarms";
        return KAEvent();
    }
    // Convert event in memory to current KAlarm format if possible
    int version;
    KACalendar::Compat compat = KAlarmResourceCommon::getCompatibility(fileStorage, version);
    KAEvent event(kcalEvent);
    const QString mime = CalEvent::mimeType(event.category());
    if (mime.isEmpty()) {
        qCWarning(KALARMDIRRESOURCE_LOG) << "loadFile: KAEvent has no usable alarms:" << event.id();
        return KAEvent();
    }
    if (!mSettings->alarmTypes().contains(mime)) {
        qCWarning(KALARMDIRRESOURCE_LOG) << "loadFile: KAEvent has wrong alarm type for resource:" << mime;
        return KAEvent();
    }
    event.setCompatibility(compat);
    return event;
}

/******************************************************************************
* After a file/event has been removed, load the next file in the list for the
* event ID.
* Reply = new event, or invalid if none.
*/
KAEvent KAlarmDirResource::loadNextFile(const QString &eventId, const QString &file)
{
    QString nextFile = file;
    while (!nextFile.isEmpty()) {
        // There is another file with the same ID - load it
        const KAEvent event = loadFile(filePath(nextFile), nextFile);
        if (event.isValid()) {
            addEventFile(event, nextFile);
            mFileEventIds.insert(nextFile, event.id());
            return event;
        }
        mFileEventIds.remove(nextFile);
        nextFile = removeEventFile(eventId, nextFile);
    }
    return KAEvent();
}

/******************************************************************************
* Retrieve an event from the calendar, whose uid and Akonadi id are given by
* 'item' (item.remoteId() and item.id() respectively).
* Set the event into a new item's payload, and signal its retrieval by calling
* itemRetrieved(newitem).
*/
bool KAlarmDirResource::retrieveItem(const Akonadi::Item &item, const QSet<QByteArray> &)
{
    const QString rid = item.remoteId();
    QHash<QString, EventFile>::ConstIterator it = mEvents.constFind(rid);
    if (it == mEvents.constEnd()) {
        qCWarning(KALARMDIRRESOURCE_LOG) << "retrieveItem: Event not found:" << rid;
        Q_EMIT error(errorMessage(KAlarmResourceCommon::UidNotFound, rid));
        return false;
    }

    KAEvent event(it.value().event);
    const Item newItem = KAlarmResourceCommon::retrieveItem(item, event);
    itemRetrieved(newItem);
    return true;
}

/******************************************************************************
* Called when an item has been added to the collection.
* Store the event in a file, and set its Akonadi remote ID to the KAEvent's UID.
*/
void KAlarmDirResource::itemAdded(const Akonadi::Item &item, const Akonadi::Collection &)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "itemAdded:" << item.id();
    if (cancelIfReadOnly()) {
        return;
    }

    KAEvent event;
    if (item.hasPayload<KAEvent>()) {
        event = item.payload<KAEvent>();
    }
    if (!event.isValid()) {
        changeProcessed();
        return;
    }
    event.setCompatibility(KACalendar::Current);
    setCompatibility();

    if (!writeToFile(event)) {
        return;
    }

    addEventFile(event, event.id());

    Item newItem(item);
    newItem.setRemoteId(event.id());
//    scheduleWrite();    //???? is this needed?
    changeCommitted(newItem);
}

/******************************************************************************
* Called when an item has been changed.
* Store the changed event in a file.
*/
void KAlarmDirResource::itemChanged(const Akonadi::Item &item, const QSet<QByteArray> &)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "itemChanged:" << item.id() << ", remote ID:" << item.remoteId();
    if (cancelIfReadOnly()) {
        return;
    }
    QHash<QString, EventFile>::iterator it = mEvents.find(item.remoteId());
    if (it != mEvents.end()) {
        if (it.value().event.isReadOnly()) {
            qCWarning(KALARMDIRRESOURCE_LOG) << "Event is read only:" << item.remoteId();
            cancelTask(errorMessage(KAlarmResourceCommon::EventReadOnly, item.remoteId()));
            return;
        }
        if (it.value().event.compatibility() != KACalendar::Current) {
            qCWarning(KALARMDIRRESOURCE_LOG) << "Event not in current format:" << item.remoteId();
            cancelTask(errorMessage(KAlarmResourceCommon::EventNotCurrentFormat, item.remoteId()));
            return;
        }
    }

    KAEvent event;
    if (item.hasPayload<KAEvent>()) {
        event = item.payload<KAEvent>();
    }
    if (!event.isValid()) {
        changeProcessed();
        return;
    }
#if 0
    QString errorMsg;
    KAEvent event = KAlarmResourceCommon::checkItemChanged(item, errorMsg);
    if (!event.isValid()) {
        if (errorMsg.isEmpty()) {
            changeProcessed();
        } else {
            cancelTask(errorMsg);
        }
        return;
    }
#endif
    event.setCompatibility(KACalendar::Current);
    if (mCompatibility != KACalendar::Current) {
        setCompatibility();
    }

    if (!writeToFile(event)) {
        return;
    }

    it.value().event = event;

    changeCommitted(item);
}

/******************************************************************************
* Called when an item has been deleted.
* Delete the item's file.
*/
void KAlarmDirResource::itemRemoved(const Akonadi::Item &item)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "itemRemoved:" << item.id();
    if (cancelIfReadOnly()) {
        return;
    }

    removeEvent(item.remoteId(), true);
    setCompatibility();
    changeProcessed();
}

/******************************************************************************
* Remove an event from the indexes, and optionally delete its file.
*/
void KAlarmDirResource::removeEvent(const QString &eventId, bool deleteFile)
{
    QString file = eventId;
    QString nextFile;
    QHash<QString, EventFile>::iterator it = mEvents.find(eventId);
    if (it != mEvents.end()) {
        file = it.value().files[0];
        nextFile = removeEventFile(eventId, file);
        mFileEventIds.remove(file);
        DEBUG_DATA("removeEvent:");
    }
    if (deleteFile) {
        QFile::remove(filePath(file));
    }

    loadNextFile(eventId, nextFile);   // load any other file with the same event ID
}

/******************************************************************************
* If the resource is read-only, cancel the task andQ_EMIT an error.
* Reply = true if cancelled.
*/
bool KAlarmDirResource::cancelIfReadOnly()
{
    if (mSettings->readOnly()) {
        qCWarning(KALARMDIRRESOURCE_LOG) << "Calendar is read-only:" << directoryName();
        Q_EMIT error(i18nc("@info", "Trying to write to a read-only calendar: '%1'", directoryName()));
        cancelTask();
        return true;
    }
    return false;
}

/******************************************************************************
* Write an event to a file. The file name is the event's id.
*/
bool KAlarmDirResource::writeToFile(const KAEvent &event)
{
    Event::Ptr kcalEvent(new Event);
    event.updateKCalEvent(kcalEvent, KAEvent::UID_SET);
    MemoryCalendar::Ptr calendar(new MemoryCalendar(QTimeZone::utc()));
    KACalendar::setKAlarmVersion(calendar);   // set the KAlarm custom property
    if (!calendar->addIncidence(kcalEvent)) {
        qCritical() << "Error adding event with id" << event.id();
        Q_EMIT error(errorMessage(KAlarmResourceCommon::CalendarAdd, event.id()));
        cancelTask();
        return false;
    }

    mChangedFiles += event.id();    // suppress KDirWatch processing for this write

    const QString path = filePath(event.id());
    qCDebug(KALARMDIRRESOURCE_LOG) << "writeToFile:" << event.id() << " File:" << path;
    FileStorage::Ptr fileStorage(new FileStorage(calendar, path, new ICalFormat()));
    if (!fileStorage->save()) {
        Q_EMIT error(i18nc("@info", "Failed to save event file: %1", path));
        cancelTask();
        return false;
    }
    return true;
}

/******************************************************************************
* Create the resource's collection.
*/
void KAlarmDirResource::retrieveCollections()
{
    QString rid = mSettings->path();
    if (!mCollectionFetched && rid.isEmpty()) {
        // The resource config seems to be missing. Execute this function
        // once the collection config has been set up.
        mWaitingToRetrieve = true;
        return;
    }

    qCDebug(KALARMDIRRESOURCE_LOG) << "retrieveCollections";
    Collection c;
    c.setParentCollection(Collection::root());
    c.setRemoteId(rid);
    c.setContentMimeTypes(mSettings->alarmTypes());
    setNameRights(c);

    // Don't update CollectionAttribute here, since it hasn't yet been fetched
    // from Akonadi database.

    Collection::List list;
    list << c;
    collectionsRetrieved(list);
}

/******************************************************************************
* Set the collection's name and rights.
* It is the caller's responsibility to notify the Akonadi server.
*/
void KAlarmDirResource::setNameRights(Collection &c)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "setNameRights";
    const QString display = mSettings->displayName();
    c.setName(display.isEmpty() ? name() : display);
    EntityDisplayAttribute *attr = c.attribute<EntityDisplayAttribute>(Collection::AddIfMissing);
    attr->setDisplayName(name());
    attr->setIconName(QStringLiteral("kalarm"));
    if (mSettings->readOnly()) {
        c.setRights(Collection::CanChangeCollection);
    } else {
        Collection::Rights rights = Collection::ReadOnly;
        rights |= Collection::CanChangeItem;
        rights |= Collection::CanCreateItem;
        rights |= Collection::CanDeleteItem;
        rights |= Collection::CanChangeCollection;
        c.setRights(rights);
    }
    qCDebug(KALARMDIRRESOURCE_LOG) << "setNameRights: end";
}

/******************************************************************************
* Retrieve all events from the directory, and set each into a new item's
* payload. Items are identified by their remote IDs. The Akonadi ID is not
* used.
* Signal the retrieval of the items by calling itemsRetrieved(items), which
* updates Akonadi with any changes to the items. itemsRetrieved() compares
* the new and old items, matching them on the remoteId(). If the flags or
* payload have changed, or the Item has any new Attributes, the Akonadi
* storage is updated.
*/
void KAlarmDirResource::retrieveItems(const Akonadi::Collection &collection)
{
    mCollectionId = collection.id();   // note the one and only collection for this resource
    qCDebug(KALARMDIRRESOURCE_LOG) << "retrieveItems: collection" << mCollectionId;

    // Set the collection's compatibility status
    KAlarmResourceCommon::setCollectionCompatibility(collection, mCompatibility, mVersion);

    // Fetch the list of valid mime types
    const QStringList mimeTypes = mSettings->alarmTypes();

    // Retrieve events
    Item::List items;
    foreach (const EventFile &data, mEvents) {
        const KAEvent &event = data.event;
        const QString mime = CalEvent::mimeType(event.category());
        if (mime.isEmpty()) {
            qCWarning(KALARMDIRRESOURCE_LOG) << "retrieveItems: KAEvent has no alarms:" << event.id();
            continue;   // event has no usable alarms
        }
        if (!mimeTypes.contains(mime)) {
            continue;    // restrict alarms returned to the defined types
        }

        Item item(mime);
        item.setRemoteId(event.id());
        item.setPayload(event);
        items.append(item);
    }

    itemsRetrieved(items);
}

/******************************************************************************
* Called when the collection has been changed.
* Set its display name if that has changed.
*/
void KAlarmDirResource::collectionChanged(const Akonadi::Collection &collection)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "collectionChanged";
    // If the collection has a new display name, set the resource's display
    // name the same, and save to the settings.
    const QString newName = collection.displayName();
    if (!newName.isEmpty() && newName != name()) {
        setName(newName);
    }
    if (newName != mSettings->displayName()) {
        mSettings->setDisplayName(newName);
        mSettings->save();
    }

    changeCommitted(collection);
}

/******************************************************************************
* Called when a file has been created in the directory.
*/
void KAlarmDirResource::fileCreated(const QString &path)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "fileCreated:" << path;
    if (path == directoryName()) {
        // The directory has been created. Load all files in it, and
        // tell the Akonadi server to create an Item for each event.
        loadFiles(true);
        foreach (const EventFile &data, mEvents) {
            createItem(data.event);
        }
    } else {
        const QString file = fileName(path);
        int i = mChangedFiles.indexOf(file);
        if (i >= 0) {
            mChangedFiles.removeAt(i);    // the file was updated by this resource
        } else if (isFileValid(file)) {
            if (createItemAndIndex(path, file)) {
                setCompatibility();
            }
            DEBUG_DATA("fileCreated:");
        }
    }
}

/******************************************************************************
* Called when a file has changed in the directory.
*/
void KAlarmDirResource::fileChanged(const QString &path)
{
    if (path != directoryName()) {
        qCDebug(KALARMDIRRESOURCE_LOG) << "fileChanged:" << path;
        const QString file = fileName(path);
        int i = mChangedFiles.indexOf(file);
        if (i >= 0) {
            mChangedFiles.removeAt(i);    // the file was updated by this resource
        } else if (isFileValid(file)) {
            QString nextFile, oldId;
            KAEvent oldEvent;
            const KAEvent event = loadFile(path, file);
            // Get the file's old event ID
            QHash<QString, QString>::iterator fit = mFileEventIds.find(file);
            if (fit != mFileEventIds.end()) {
                oldId = fit.value();
                if (event.id() != oldId) {
                    // The file's event ID has changed - remove the old event
                    nextFile = removeEventFile(oldId, file, &oldEvent);
                    if (event.isValid()) {
                        fit.value() = event.id();
                    } else {
                        mFileEventIds.erase(fit);
                    }
                }
            } else {
                // The file didn't contain an event before.
                if (event.isValid()) {
                    // Save details of the new event.
                    mFileEventIds.insert(file, event.id());
                } else {
                    // The file still doesn't contain a recognised event.
                    return;
                }
            }
            addEventFile(event, file);

            KAEvent e = loadNextFile(oldId, nextFile);   // load any other file with the same event ID
            setCompatibility();

            // Tell the Akonadi server to amend the Item for the event
            if (event.id() != oldId) {
                if (e.isValid()) {
                    modifyItem(e);
                } else {
                    deleteItem(oldEvent);
                }
                createItem(event);   // create a new Item for the new event ID
            } else {
                modifyItem(event);
            }
            DEBUG_DATA("fileChanged:");
        }
    }
}

/******************************************************************************
* Called when a file has been deleted in the directory.
*/
void KAlarmDirResource::fileDeleted(const QString &path)
{
    qCDebug(KALARMDIRRESOURCE_LOG) << "fileDeleted:" << path;
    if (path == directoryName()) {
        // The directory has been deleted
        mEvents.clear();
        mFileEventIds.clear();

        // Tell the Akonadi server to delete all Items in the collection
        Collection c(mCollectionId);
        ItemDeleteJob *job = new ItemDeleteJob(c);
        connect(job, &CollectionFetchJob::result, this, &KAlarmDirResource::jobDone);
    } else {
        // A single file has been deleted
        const QString file = fileName(path);
        if (isFileValid(file)) {
            QHash<QString, QString>::iterator fit = mFileEventIds.find(file);
            if (fit != mFileEventIds.end()) {
                QString eventId = fit.value();
                KAEvent event;
                QString nextFile = removeEventFile(eventId, file, &event);
                mFileEventIds.erase(fit);

                KAEvent e = loadNextFile(eventId, nextFile);   // load any other file with the same event ID
                setCompatibility();

                if (e.isValid()) {
                    // Tell the Akonadi server to amend the Item for the event
                    modifyItem(e);
                } else {
                    // Tell the Akonadi server to delete the Item for the event
                    deleteItem(event);
                }
                DEBUG_DATA("fileDeleted:");
            }
        }
    }
}

/******************************************************************************
* Tell the Akonadi server to create an Item for a given file's event, and add
* it to the indexes.
*/
bool KAlarmDirResource::createItemAndIndex(const QString &path, const QString &file)
{
    const KAEvent event = loadFile(path, file);
    if (event.isValid()) {
        // Tell the Akonadi server to create an Item for the event
        if (createItem(event)) {
            addEventFile(event, file);
            mFileEventIds.insert(file, event.id());

            return true;
        }
    }
    return false;
}

/******************************************************************************
* Tell the Akonadi server to create an Item for a given event.
*/
bool KAlarmDirResource::createItem(const KAEvent &event)
{
    Item item;
    if (!KAlarmCal::setItemPayload(item, event, mSettings->alarmTypes())) {
        qCWarning(KALARMDIRRESOURCE_LOG) << "createItem: Invalid mime type for collection";
        return false;
    }
    Collection c(mCollectionId);
    item.setParentCollection(c);
    item.setRemoteId(event.id());
    ItemCreateJob *job = new ItemCreateJob(item, c);
    connect(job, &CollectionFetchJob::result, this, &KAlarmDirResource::jobDone);
    return true;
}

/******************************************************************************
* Tell the Akonadi server to amend the Item for a given event.
*/
bool KAlarmDirResource::modifyItem(const KAEvent &event)
{
    Item item;
    if (!KAlarmCal::setItemPayload(item, event, mSettings->alarmTypes())) {
        qCWarning(KALARMDIRRESOURCE_LOG) << "modifyItem: Invalid mime type for collection";
        return false;
    }
    Collection c(mCollectionId);
    item.setParentCollection(c);
    item.setRemoteId(event.id());
    ItemModifyJob *job = new ItemModifyJob(item);
    job->disableRevisionCheck();
    connect(job, &CollectionFetchJob::result, this, &KAlarmDirResource::jobDone);
    return true;
}

/******************************************************************************
* Tell the Akonadi server to delete the Item for a given event.
*/
void KAlarmDirResource::deleteItem(const KAEvent &event)
{
    Item item(CalEvent::mimeType(event.category()));
    Collection c(mCollectionId);
    item.setParentCollection(c);
    item.setRemoteId(event.id());
    ItemDeleteJob *job = new ItemDeleteJob(item);
    connect(job, &CollectionFetchJob::result, this, &KAlarmDirResource::jobDone);
}

/******************************************************************************
* Called when a collection or item job has completed.
* Checks for any error.
*/
void KAlarmDirResource::jobDone(KJob *j)
{
    if (j->error()) {
        qCritical() << j->metaObject()->className() << "error:" << j->errorString();
    }
}

/******************************************************************************
* Create the directory if it doesn't already exist, and ensure that it
* contains a WARNING_README.txt file.
*/
void KAlarmDirResource::initializeDirectory() const
{
    const QDir dir(directoryName());
    const QString dirPath = dir.absolutePath();
    qCDebug(KALARMDIRRESOURCE_LOG) << "initializeDirectory" << dirPath;

    // If folder does not exist, create it
    if (!dir.exists()) {
        qCDebug(KALARMDIRRESOURCE_LOG) << "initializeDirectory: creating" << dirPath;
        QDir::root().mkpath(dirPath);
    }

    // Check whether warning file is in place...
    QFile file(dirPath + QLatin1Char('/') + QLatin1String(warningFile));
    if (!file.exists()) {
        // ... if not, create it
        file.open(QIODevice::WriteOnly);
        file.write("Important Warning!!!\n"
                   "Do not create or copy items inside this folder manually:\n"
                   "they are managed by the Akonadi framework!\n");
        file.close();
    }
}

QString KAlarmDirResource::directoryName() const
{
    return mSettings->path();
}

/******************************************************************************
* Return the full path of an event file.
* 'file' should not contain any directory component.
*/
QString KAlarmDirResource::filePath(const QString &file) const
{
    return mSettings->path() + QLatin1Char('/') + file;
}

/******************************************************************************
* Strip the directory path from a file name.
*/
QString KAlarmDirResource::fileName(const QString &path) const
{
    const QFileInfo fi(path);
    if (fi.isDir() || fi.isBundle()) {
        return QString();
    }
    if (fi.path() == mSettings->path()) {
        return fi.fileName();
    }
    return path;
}

/******************************************************************************
* Evaluate the version compatibility status of the calendar. This is the OR of
* the statuses of the individual events.
*/
void KAlarmDirResource::setCompatibility(bool writeAttr)
{
    static const KACalendar::Compat AllCompat(KACalendar::Current | KACalendar::Convertible | KACalendar::Incompatible);

    const KACalendar::Compat oldCompatibility = mCompatibility;
    const int oldVersion = mVersion;
    if (mEvents.isEmpty()) {
        mCompatibility = KACalendar::Current;
    } else {
        mCompatibility = KACalendar::Unknown;
        foreach (const EventFile &data, mEvents) {
            const KAEvent &event = data.event;
            mCompatibility |= event.compatibility();
            if ((mCompatibility & AllCompat) == AllCompat) {
                break;
            }
        }
    }
    mVersion = (mCompatibility == KACalendar::Current) ? KACalendar::CurrentFormat : KACalendar::MixedFormat;
    if (writeAttr && (mCompatibility != oldCompatibility || mVersion != oldVersion)) {
        const Collection c(mCollectionId);
        if (c.isValid()) {
            KAlarmResourceCommon::setCollectionCompatibility(c, mCompatibility, mVersion);
        }
    }
}

/******************************************************************************
* Add an event/file combination to the mEvents map.
*/
void KAlarmDirResource::addEventFile(const KAEvent &event, const QString &file)
{
    if (event.isValid()) {
        QHash<QString, EventFile>::iterator it = mEvents.find(event.id());
        if (it != mEvents.end()) {
            EventFile &data = it.value();
            data.event = event;
            data.files.removeAll(file);   // in case it isn't the first file
            data.files.prepend(file);
        } else {
            mEvents.insert(event.id(), EventFile(event, QStringList(file)));
        }
    }
}

/******************************************************************************
* Remove an event ID/file combination from the mEvents map.
* Reply = next file with the same event ID.
*/
QString KAlarmDirResource::removeEventFile(const QString &eventId, const QString &file, KAEvent *event)
{
    QHash<QString, EventFile>::iterator it = mEvents.find(eventId);
    if (it != mEvents.end()) {
        if (event) {
            *event = it.value().event;
        }
        it.value().files.removeAll(file);
        if (!it.value().files.isEmpty()) {
            return it.value().files[0];
        }
        mEvents.erase(it);
    } else if (event) {
        *event = KAEvent();
    }
    return QString();
}

/******************************************************************************
* Check whether a file is to be ignored.
* Reply = false if file is to be ignored.
*/
bool KAlarmDirResource::isFileValid(const QString &file) const
{
    return !file.isEmpty()
           && !file.startsWith(QLatin1Char('.')) && !file.endsWith(QLatin1Char('~'))
           && file != QLatin1String(warningFile)
           && QFileInfo::exists(filePath(file)); // a temporary file may no longer exist
}

AKONADI_RESOURCE_MAIN(KAlarmDirResource)
