/*
 *  Copyright (C) 2018 Aetf <aetf@unlimitedcodeworks.xyz>
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 2 or (at your option)
 *  version 3 of the License.
 *
 *  This program 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 General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

#include "Collection.h"

#include "fdosecrets/FdoSecretsSettings.h"
#include "fdosecrets/objects/Item.h"
#include "fdosecrets/objects/Prompt.h"
#include "fdosecrets/objects/Service.h"
#include "fdosecrets/objects/Session.h"

#include "core/Config.h"
#include "core/Database.h"
#include "core/Tools.h"
#include "gui/DatabaseTabWidget.h"
#include "gui/DatabaseWidget.h"

#include <QFileInfo>
#include <QRegularExpression>

namespace FdoSecrets
{

    Collection::Collection(Service* parent, DatabaseWidget* backend)
        : DBusObject(parent)
        , m_backend(backend)
        , m_exposedGroup(nullptr)
        , m_registered(false)
    {
        // whenever the file path or the database object itself change, we do a full reload.
        connect(backend, &DatabaseWidget::databaseFilePathChanged, this, &Collection::reloadBackend);
        connect(backend, &DatabaseWidget::databaseReplaced, this, &Collection::reloadBackend);

        // also remember to clear/populate the database when lock state changes.
        connect(backend, &DatabaseWidget::databaseUnlocked, this, &Collection::onDatabaseLockChanged);
        connect(backend, &DatabaseWidget::databaseLocked, this, &Collection::onDatabaseLockChanged);

        // get notified whenever unlock db dialog finishes
        connect(parent, &Service::doneUnlockDatabaseInDialog, this, [this](bool accepted, DatabaseWidget* dbWidget) {
            if (!dbWidget || dbWidget != m_backend) {
                return;
            }
            emit doneUnlockCollection(accepted);
        });

        reloadBackend();
    }

    void Collection::reloadBackend()
    {
        if (m_registered) {
            // delete all items
            // this has to be done because the backend is actually still there, just we don't expose them
            // NOTE: Do NOT use a for loop, because Item::doDelete will remove itself from m_items.
            while (!m_items.isEmpty()) {
                m_items.first()->doDelete();
            }
            cleanupConnections();

            unregisterCurrentPath();
            m_registered = false;
        }

        Q_ASSERT(m_backend);

        // make sure we have updated copy of the filepath, which is used to identify the database.
        m_backendPath = m_backend->database()->filePath();

        // the database may not have a name (derived from filePath) yet, which may happen if it's newly created.
        // defer the registration to next time a file path change happens.
        if (!name().isEmpty()) {
            registerWithPath(
                QStringLiteral(DBUS_PATH_TEMPLATE_COLLECTION).arg(p()->objectPath().path(), encodePath(name())),
                new CollectionAdaptor(this));
            m_registered = true;
        }

        // populate contents after expose on dbus, because items rely on parent's dbus object path
        if (!backendLocked()) {
            populateContents();
        } else {
            cleanupConnections();
        }
    }

    DBusReturn<void> Collection::ensureBackend() const
    {
        if (!m_backend) {
            return DBusReturn<>::Error(QStringLiteral(DBUS_ERROR_SECRET_NO_SUCH_OBJECT));
        }
        return {};
    }

    DBusReturn<void> Collection::ensureUnlocked() const
    {
        if (backendLocked()) {
            return DBusReturn<>::Error(QStringLiteral(DBUS_ERROR_SECRET_IS_LOCKED));
        }
        return {};
    }

    DBusReturn<const QList<Item*>> Collection::items() const
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }
        return m_items;
    }

    DBusReturn<QString> Collection::label() const
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }

        if (backendLocked()) {
            return name();
        }
        return m_backend->database()->metadata()->name();
    }

    DBusReturn<void> Collection::setLabel(const QString& label)
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }
        ret = ensureUnlocked();
        if (ret.isError()) {
            return ret;
        }

        m_backend->database()->metadata()->setName(label);
        return {};
    }

    DBusReturn<bool> Collection::locked() const
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }
        return backendLocked();
    }

    DBusReturn<qulonglong> Collection::created() const
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }
        ret = ensureUnlocked();
        if (ret.isError()) {
            return ret;
        }
        return static_cast<qulonglong>(m_backend->database()->rootGroup()->timeInfo().creationTime().toMSecsSinceEpoch()
                                       / 1000);
    }

    DBusReturn<qulonglong> Collection::modified() const
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }
        ret = ensureUnlocked();
        if (ret.isError()) {
            return ret;
        }
        // FIXME: there seems not to have a global modified time.
        // Use a more accurate time, considering all metadata, group, entry.
        return static_cast<qulonglong>(
            m_backend->database()->rootGroup()->timeInfo().lastModificationTime().toMSecsSinceEpoch() / 1000);
    }

    DBusReturn<PromptBase*> Collection::deleteCollection()
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }

        // Delete means close database
        auto prompt = new DeleteCollectionPrompt(service(), this);
        if (backendLocked()) {
            // this won't raise a dialog, immediate execute
            auto pret = prompt->prompt({});
            if (pret.isError()) {
                return pret;
            }
            prompt = nullptr;
        }
        // defer the close to the prompt
        return prompt;
    }

    DBusReturn<const QList<Item*>> Collection::searchItems(const StringStringMap& attributes)
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }
        ret = ensureUnlocked();
        if (ret.isError()) {
            // searchItems should work, whether `this` is locked or not.
            // however, we can't search items the same way as in gnome-keying,
            // because there's no database at all when locked.
            return QList<Item*>{};
        }

        // shortcut logic for Uuid/Path attributes, as they can uniquely identify an item.
        if (attributes.contains(ItemAttributes::UuidKey)) {
            auto uuid = QUuid::fromRfc4122(QByteArray::fromHex(attributes.value(ItemAttributes::UuidKey).toLatin1()));
            auto entry = m_exposedGroup->findEntryByUuid(uuid);
            if (entry) {
                return QList<Item*>{m_entryToItem.value(entry)};
            } else {
                return QList<Item*>{};
            }
        }

        if (attributes.contains(ItemAttributes::PathKey)) {
            auto path = attributes.value(ItemAttributes::PathKey);
            auto entry = m_exposedGroup->findEntryByPath(path);
            if (entry) {
                return QList<Item*>{m_entryToItem.value(entry)};
            } else {
                return QList<Item*>{};
            }
        }

        QList<EntrySearcher::SearchTerm> terms;
        for (auto it = attributes.constBegin(); it != attributes.constEnd(); ++it) {
            terms << attributeToTerm(it.key(), it.value());
        }

        QList<Item*> items;
        const auto foundEntries = EntrySearcher(false, true).search(terms, m_exposedGroup);
        items.reserve(foundEntries.size());
        for (const auto& entry : foundEntries) {
            items << m_entryToItem.value(entry);
        }
        return items;
    }

    EntrySearcher::SearchTerm Collection::attributeToTerm(const QString& key, const QString& value)
    {
        static QMap<QString, EntrySearcher::Field> attrKeyToField{
            {EntryAttributes::TitleKey, EntrySearcher::Field::Title},
            {EntryAttributes::UserNameKey, EntrySearcher::Field::Username},
            {EntryAttributes::URLKey, EntrySearcher::Field::Url},
            {EntryAttributes::NotesKey, EntrySearcher::Field::Notes},
        };

        EntrySearcher::SearchTerm term{};
        term.field = attrKeyToField.value(key, EntrySearcher::Field::AttributeValue);
        term.word = key;
        term.exclude = false;

        const auto useWildcards = false;
        const auto exactMatch = true;
        const auto caseSensitive = true;
        term.regex = Tools::convertToRegex(QRegularExpression::escape(value), useWildcards, exactMatch, caseSensitive);

        return term;
    }

    DBusReturn<Item*>
    Collection::createItem(const QVariantMap& properties, const SecretStruct& secret, bool replace, PromptBase*& prompt)
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }
        ret = ensureUnlocked();
        if (ret.isError()) {
            return ret;
        }

        if (!pathToObject<Session>(secret.session)) {
            return DBusReturn<>::Error(QStringLiteral(DBUS_ERROR_SECRET_NO_SESSION));
        }

        prompt = nullptr;

        bool newlyCreated = true;
        Item* item = nullptr;
        QString itemPath;
        StringStringMap attributes;

        auto iterAttr = properties.find(QStringLiteral(DBUS_INTERFACE_SECRET_ITEM ".Attributes"));
        if (iterAttr != properties.end()) {
            attributes = iterAttr.value().value<StringStringMap>();

            itemPath = attributes.value(ItemAttributes::PathKey);

            // check existing item using attributes
            auto existings = searchItems(attributes);
            if (existings.isError()) {
                return existings;
            }
            if (!existings.value().isEmpty() && replace) {
                item = existings.value().front();
                newlyCreated = false;
            }
        }

        if (!item) {
            // normalize itemPath
            itemPath = itemPath.startsWith('/') ? QString{} : QStringLiteral("/") + itemPath;

            // split itemPath to groupPath and itemName
            auto components = itemPath.split('/');
            Q_ASSERT(components.size() >= 2);

            auto itemName = components.takeLast();
            Group* group = findCreateGroupByPath(components.join('/'));

            // create new Entry in backend
            auto* entry = new Entry();
            entry->setUuid(QUuid::createUuid());
            entry->setTitle(itemName);
            entry->setUsername(m_backend->database()->metadata()->defaultUserName());
            group->applyGroupIconOnCreateTo(entry);

            entry->setGroup(group);

            // when creation finishes in backend, we will already have item
            item = m_entryToItem.value(entry, nullptr);

            if (!item) {
                // may happen if entry somehow ends up in recycle bin
                return DBusReturn<>::Error(QStringLiteral(DBUS_ERROR_SECRET_NO_SUCH_OBJECT));
            }
        }

        ret = item->setProperties(properties);
        if (ret.isError()) {
            if (newlyCreated) {
                item->doDelete();
            }
            return ret;
        }
        ret = item->setSecret(secret);
        if (ret.isError()) {
            if (newlyCreated) {
                item->doDelete();
            }
            return ret;
        }

        return item;
    }

    DBusReturn<void> Collection::setProperties(const QVariantMap& properties)
    {
        auto label = properties.value(QStringLiteral(DBUS_INTERFACE_SECRET_COLLECTION ".Label")).toString();

        auto ret = setLabel(label);
        if (ret.isError()) {
            return ret;
        }

        return {};
    }

    const QSet<QString> Collection::aliases() const
    {
        return m_aliases;
    }

    DBusReturn<void> Collection::addAlias(QString alias)
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }

        alias = encodePath(alias);

        if (m_aliases.contains(alias)) {
            return {};
        }

        emit aliasAboutToAdd(alias);

        bool ok = QDBusConnection::sessionBus().registerObject(
            QStringLiteral(DBUS_PATH_TEMPLATE_ALIAS).arg(p()->objectPath().path(), alias), this);
        if (ok) {
            m_aliases.insert(alias);
            emit aliasAdded(alias);
        }

        return {};
    }

    DBusReturn<void> Collection::removeAlias(QString alias)
    {
        auto ret = ensureBackend();
        if (ret.isError()) {
            return ret;
        }

        alias = encodePath(alias);

        if (!m_aliases.contains(alias)) {
            return {};
        }

        QDBusConnection::sessionBus().unregisterObject(
            QStringLiteral(DBUS_PATH_TEMPLATE_ALIAS).arg(p()->objectPath().path(), alias));

        m_aliases.remove(alias);
        emit aliasRemoved(alias);

        return {};
    }

    QString Collection::name() const
    {
        if (m_backendPath.isEmpty()) {
            // This is a newly created db without saving to file.
            // This name is also used to register dbus path.
            // For simplicity, we don't monitor the name change.
            // So the dbus object path is not updated if the db name
            // changes. This should not be a problem because once the database
            // gets saved, the dbus path will be updated to use filename and
            // everything back to normal.
            return m_backend->database()->metadata()->name();
        }
        return QFileInfo(m_backendPath).baseName();
    }

    DatabaseWidget* Collection::backend() const
    {
        return m_backend;
    }

    void Collection::onDatabaseLockChanged()
    {
        auto locked = backendLocked();
        if (!locked) {
            populateContents();
        } else {
            cleanupConnections();
        }
        emit collectionLockChanged(locked);
        emit collectionChanged();
    }

    void Collection::populateContents()
    {
        if (!m_registered) {
            return;
        }

        // we have an unlocked db

        auto newUuid = FdoSecrets::settings()->exposedGroup(m_backend->database());
        auto newGroup = m_backend->database()->rootGroup()->findGroupByUuid(newUuid);
        if (!newGroup || inRecycleBin(newGroup)) {
            // no exposed group, delete self
            doDelete();
            return;
        }

        // clean up old group connections
        cleanupConnections();

        m_exposedGroup = newGroup;

        // Attach signal to update exposed group settings if the group was removed.
        //
        // When the group object is normally deleted due to ~Database, the databaseReplaced
        // signal should be first emitted, and we will clean up connection in reloadDatabase,
        // so this handler won't be triggered.
        connect(m_exposedGroup.data(), &Group::groupAboutToRemove, this, [this](Group* toBeRemoved) {
            if (backendLocked()) {
                return;
            }
            auto db = m_backend->database();
            if (toBeRemoved->database() != db) {
                // should not happen, but anyway.
                // somehow our current database has been changed, and the old group is being deleted
                // possibly logic changes in replaceDatabase.
                return;
            }
            auto uuid = FdoSecrets::settings()->exposedGroup(db);
            auto exposedGroup = db->rootGroup()->findGroupByUuid(uuid);
            if (toBeRemoved == exposedGroup) {
                // reset the exposed group to none
                FdoSecrets::settings()->setExposedGroup(db, {});
            }
        });
        // Another possibility is the group being moved to recycle bin.
        connect(m_exposedGroup.data(), &Group::groupModified, this, [this]() {
            if (inRecycleBin(m_exposedGroup->parentGroup())) {
                // reset the exposed group to none
                FdoSecrets::settings()->setExposedGroup(m_backend->database().data(), {});
            }
        });

        // Monitor exposed group settings
        connect(m_backend->database()->metadata()->customData(), &CustomData::customDataModified, this, [this]() {
            if (!m_exposedGroup || backendLocked()) {
                return;
            }
            if (m_exposedGroup->uuid() == FdoSecrets::settings()->exposedGroup(m_backend->database())) {
                // no change
                return;
            }
            onDatabaseExposedGroupChanged();
        });

        // Add items for existing entry
        const auto entries = m_exposedGroup->entriesRecursive(false);
        for (const auto& entry : entries) {
            onEntryAdded(entry, false);
        }

        connectGroupSignalRecursive(m_exposedGroup);
    }

    void Collection::onDatabaseExposedGroupChanged()
    {
        // delete all items
        // this has to be done because the backend is actually still there
        // just we don't expose them
        for (const auto& item : asConst(m_items)) {
            item->doDelete();
        }

        // repopulate
        if (!backendLocked()) {
            populateContents();
        }
    }

    void Collection::onEntryAdded(Entry* entry, bool emitSignal)
    {
        if (inRecycleBin(entry)) {
            return;
        }

        auto item = new Item(this, entry);
        m_items << item;
        m_entryToItem[entry] = item;

        // forward delete signals
        connect(entry->group(), &Group::entryAboutToRemove, item, [item](Entry* toBeRemoved) {
            if (item->backend() == toBeRemoved) {
                item->doDelete();
            }
        });

        // relay signals
        connect(item, &Item::itemChanged, this, [this, item]() { emit itemChanged(item); });
        connect(item, &Item::itemAboutToDelete, this, [this, item]() {
            m_items.removeAll(item);
            m_entryToItem.remove(item->backend());
            emit itemDeleted(item);
        });

        if (emitSignal) {
            emit itemCreated(item);
        }
    }

    void Collection::connectGroupSignalRecursive(Group* group)
    {
        if (inRecycleBin(group)) {
            return;
        }

        connect(group, &Group::groupModified, this, &Collection::collectionChanged);
        connect(group, &Group::entryAdded, this, [this](Entry* entry) { onEntryAdded(entry, true); });

        const auto children = group->children();
        for (const auto& cg : children) {
            connectGroupSignalRecursive(cg);
        }
    }

    Service* Collection::service() const
    {
        return qobject_cast<Service*>(parent());
    }

    bool Collection::doLock()
    {
        Q_ASSERT(m_backend);

        return m_backend->lock();
    }

    void Collection::doUnlock()
    {
        Q_ASSERT(m_backend);

        service()->doUnlockDatabaseInDialog(m_backend);
    }

    void Collection::doDelete()
    {
        if (!m_backend) {
            // I'm already deleted
            return;
        }

        emit collectionAboutToDelete();

        unregisterCurrentPath();

        // remove alias manually to trigger signal
        for (const auto& a : aliases()) {
            removeAlias(a).okOrDie();
        }

        // cleanup connection on Database
        cleanupConnections();
        // cleanup connection on Backend itself
        m_backend->disconnect(this);
        parent()->disconnect(this);

        m_exposedGroup = nullptr;

        // reset backend and delete self
        m_backend = nullptr;
        deleteLater();
    }

    void Collection::cleanupConnections()
    {
        m_backend->database()->metadata()->customData()->disconnect(this);
        if (m_exposedGroup) {
            for (const auto group : m_exposedGroup->groupsRecursive(true)) {
                group->disconnect(this);
            }
        }

        m_items.clear();
    }

    QString Collection::backendFilePath() const
    {
        return m_backendPath;
    }

    Group* Collection::exposedRootGroup() const
    {
        return m_exposedGroup;
    }

    bool Collection::backendLocked() const
    {
        return !m_backend || !m_backend->database()->isInitialized() || m_backend->isLocked();
    }

    void Collection::doDeleteEntries(QList<Entry*> entries)
    {
        m_backend->deleteEntries(std::move(entries));
    }

    Group* Collection::findCreateGroupByPath(const QString& groupPath)
    {
        auto group = m_exposedGroup->findGroupByPath(groupPath);
        if (group) {
            return group;
        }

        // groupPath can't be empty here, because otherwise it will match m_exposedGroup and was returned above
        Q_ASSERT(!groupPath.isEmpty());

        auto groups = groupPath.split('/', QString::SkipEmptyParts);
        auto groupName = groups.takeLast();

        // create parent group
        auto parentGroup = findCreateGroupByPath(groups.join('/'));

        // create this group
        group = new Group();
        group->setUuid(QUuid::createUuid());
        group->setName(groupName);
        group->setIcon(Group::DefaultIconNumber);
        group->setParent(parentGroup);

        return group;
    }

    bool Collection::inRecycleBin(Group* group) const
    {
        Q_ASSERT(m_backend);

        if (!group) {
            // the root group's parent is nullptr, we treat it as not in recycle bin.
            return false;
        }

        if (!m_backend->database()->metadata()) {
            return false;
        }

        auto recycleBin = m_backend->database()->metadata()->recycleBin();
        if (!recycleBin) {
            return false;
        }

        return group->uuid() == recycleBin->uuid() || group->isRecycled();
    }

    bool Collection::inRecycleBin(Entry* entry) const
    {
        Q_ASSERT(entry);
        return inRecycleBin(entry->group());
    }

} // namespace FdoSecrets
