/*
    SPDX-FileCopyrightText: 2006 Pino Toscano <pino@kde.org>

    SPDX-License-Identifier: GPL-2.0-or-later
*/

#include "bookmarkmanager.h"

// qt/kde includes
#include <KBookmarkAction>
#include <KBookmarkManager>
#include <KBookmarkOwner>
#include <QDebug>
#include <QFileInfo>
#include <QGuiApplication>
#include <QHash>
#include <QSet>
#include <QStandardPaths>
#include <QUrl>

// local includes
#include "document_p.h"
#include "observer.h"
#include "page.h"

using namespace Okular;

#define foreachObserver(cmd)                                                                                                                                                                                                                   \
    {                                                                                                                                                                                                                                          \
        QSet<DocumentObserver *>::const_iterator it = d->document->m_observers.constBegin(), end = d->document->m_observers.constEnd();                                                                                                        \
        for (; it != end; ++it) {                                                                                                                                                                                                              \
            (*it)->cmd;                                                                                                                                                                                                                        \
        }                                                                                                                                                                                                                                      \
    }

#define foreachObserverD(cmd)                                                                                                                                                                                                                  \
    {                                                                                                                                                                                                                                          \
        QSet<DocumentObserver *>::const_iterator it = document->m_observers.constBegin(), end = document->m_observers.constEnd();                                                                                                              \
        for (; it != end; ++it) {                                                                                                                                                                                                              \
            (*it)->cmd;                                                                                                                                                                                                                        \
        }                                                                                                                                                                                                                                      \
    }

class OkularBookmarkAction : public KBookmarkAction
{
    Q_OBJECT
public:
    OkularBookmarkAction(const Okular::DocumentViewport &vp, const KBookmark &bk, KBookmarkOwner *owner, QObject *parent)
        : KBookmarkAction(bk, owner, parent)
    {
        if (vp.isValid()) {
            setText(QString::number(vp.pageNumber + 1) + QStringLiteral(" - ") + text());
        }
        setProperty("pageNumber", vp.pageNumber + 1);
        setProperty("htmlRef", bk.url().fragment(QUrl::FullyDecoded));
    }

    inline int pageNumber() const
    {
        return property("pageNumber").toInt();
    }

    inline QString htmlRef() const
    {
        return property("htmlRef").toString();
    }
};

static inline bool documentViewportFuzzyCompare(const DocumentViewport &vp1, const DocumentViewport &vp2)
{
    bool equal = vp1.isValid() && vp2.isValid() && (vp1.pageNumber == vp2.pageNumber) && (vp1.rePos.pos == vp2.rePos.pos);

    if (!equal) {
        return false;
    }

    if (qAbs(vp1.rePos.normalizedX - vp2.rePos.normalizedX) >= 0.000001) {
        return false;
    }

    if (qAbs(vp1.rePos.normalizedY - vp2.rePos.normalizedY) >= 0.000001) {
        return false;
    }

    return true;
}

static inline bool bookmarkLessThan(const KBookmark &b1, const KBookmark &b2)
{
    DocumentViewport vp1(b1.url().fragment(QUrl::FullyDecoded));
    DocumentViewport vp2(b2.url().fragment(QUrl::FullyDecoded));

    return vp1 < vp2;
}

static inline bool okularBookmarkActionLessThan(QAction *a1, QAction *a2)
{
    DocumentViewport vp1(static_cast<OkularBookmarkAction *>(a1)->htmlRef());
    DocumentViewport vp2(static_cast<OkularBookmarkAction *>(a2)->htmlRef());

    return vp1 < vp2;
}

static QUrl mostCanonicalUrl(const QUrl &url)
{
    if (!url.isLocalFile()) {
        return url;
    }

    const QFileInfo fi(url.toLocalFile());
    return QUrl::fromLocalFile(fi.canonicalFilePath());
}

class BookmarkManager::Private : public KBookmarkOwner
{
public:
    explicit Private(BookmarkManager *qq)
        : KBookmarkOwner()
        , q(qq)
        , document(nullptr)
        , file(QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QStringLiteral("/okular/bookmarks.xml"))
        , manager(KBookmarkManager(file))
    {
    }

    ~Private() override
    {
        knownFiles.clear();
        // no need to delete the manager, it's automatically done by KBookmarkManager
        // delete manager;
    }

    Private(const Private &) = delete;
    Private &operator=(const Private &) = delete;

    QUrl currentUrl() const override;
    QString currentTitle() const override;
    bool enableOption(BookmarkOption option) const override;
    void openBookmark(const KBookmark &bm, Qt::MouseButtons, Qt::KeyboardModifiers) override;

    QHash<QUrl, QString>::iterator bookmarkFind(const QUrl &url, bool doCreate, KBookmarkGroup *result = nullptr);

    // slots
    void _o_changed(const QString &groupAddress);

    BookmarkManager *q;
    QUrl url;
    QHash<int, int> urlBookmarks;
    DocumentPrivate *document;
    QString file;
    KBookmarkManager manager;
    QHash<QUrl, QString> knownFiles;
};

static inline QUrl urlForGroup(const KBookmark &group)
{
    if (group.url().isValid()) {
        return group.url();
    } else {
        return QUrl::fromUserInput(group.fullText());
    }
}

BookmarkManager::BookmarkManager(DocumentPrivate *document)
    : QObject(document->m_parent)
    , d(new Private(this))
{
    setObjectName(QStringLiteral("Okular::BookmarkManager"));

    d->document = document;

    connect(&d->manager, &KBookmarkManager::changed, this, [this](const QString &groupAddress) { d->_o_changed(groupAddress); });
}

BookmarkManager::~BookmarkManager()
{
    delete d;
}

// BEGIN Reimplementations from KBookmarkOwner
QUrl BookmarkManager::Private::currentUrl() const
{
    return url;
}

QString BookmarkManager::Private::currentTitle() const
{
    return url.toDisplayString();
}

bool BookmarkManager::Private::enableOption(BookmarkOption option) const
{
    Q_UNUSED(option)
    return false;
}

void BookmarkManager::Private::openBookmark(const KBookmark &bm, Qt::MouseButtons, Qt::KeyboardModifiers)
{
    Q_EMIT q->openUrl(bm.url());
}
// END Reimplementations from KBookmarkOwner

void BookmarkManager::Private::_o_changed(const QString &groupAddress)
{
    if (groupAddress.isEmpty()) {
        return;
    }

    QUrl referurl;
    // first, try to find the bookmark group whom change notification was just received
    QHash<QUrl, QString>::iterator it = knownFiles.begin(), itEnd = knownFiles.end();
    for (; it != itEnd; ++it) {
        if (it.value() == groupAddress) {
            referurl = it.key();
            knownFiles.erase(it);
            break;
        }
    }
    if (!referurl.isValid()) {
        const KBookmark bm = manager.findByAddress(groupAddress);
        // better be safe than sorry
        if (bm.isNull()) {
            return;
        }
        Q_ASSERT(bm.isGroup());
        referurl = urlForGroup(bm);
    }
    Q_ASSERT(referurl.isValid());
    Q_EMIT q->bookmarksChanged(referurl);
    // case for the url representing the current document
    // (this might happen if the same document is open in another place;
    // in such case, make really sure to be in sync)
    if (referurl == url) {
        // save the old bookmarks for the current url
        const QHash<int, int> oldUrlBookmarks = urlBookmarks;
        // set the same url again, so we reload the information we have about it
        q->setUrl(referurl);
        // then notify the observers about the changes in the bookmarks
        for (int i = 0; i < qMax(oldUrlBookmarks.size(), urlBookmarks.size()); i++) {
            bool oldContains = oldUrlBookmarks.contains(i) && oldUrlBookmarks[i] > 0;
            bool curContains = urlBookmarks.contains(i) && urlBookmarks[i] > 0;

            if (oldContains != curContains) {
                foreachObserverD(notifyPageChanged(i, DocumentObserver::Bookmark));
            } else if (oldContains && oldUrlBookmarks[i] != urlBookmarks[i]) {
                foreachObserverD(notifyPageChanged(i, DocumentObserver::Bookmark));
            }
        }
    }
    Q_EMIT q->saved();
}

QList<QUrl> BookmarkManager::files() const
{
    QList<QUrl> ret;
    KBookmarkGroup group = d->manager.root();
    for (KBookmark bm = group.first(); !bm.isNull(); bm = group.next(bm)) {
        if (bm.isSeparator() || !bm.isGroup()) {
            continue;
        }

        ret.append(urlForGroup(bm));
    }
    return ret;
}

KBookmark::List BookmarkManager::bookmarks(const QUrl &documentUrl) const
{
    const QUrl url = mostCanonicalUrl(documentUrl);
    KBookmark::List ret;
    KBookmarkGroup group = d->manager.root();
    for (KBookmark bm = group.first(); !bm.isNull(); bm = group.next(bm)) {
        if (!bm.isGroup() || urlForGroup(bm) != url) {
            continue;
        }

        KBookmarkGroup bmGroup = bm.toGroup();
        for (KBookmark b = bmGroup.first(); !b.isNull(); b = bmGroup.next(b)) {
            if (b.isSeparator() || b.isGroup()) {
                continue;
            }

            ret.append(b);
        }
        break;
    }

    return ret;
}

KBookmark::List BookmarkManager::bookmarks() const
{
    return bookmarks(d->url);
}

KBookmark::List BookmarkManager::bookmarks(int page) const
{
    const KBookmark::List bmarks = bookmarks();
    KBookmark::List ret;
    for (const KBookmark &bm : bmarks) {
        DocumentViewport vp(bm.url().fragment(QUrl::FullyDecoded));
        if (vp.isValid() && vp.pageNumber == page) {
            ret.append(bm);
        }
    }

    return ret;
}

KBookmark BookmarkManager::bookmark(int page) const
{
    const KBookmark::List bmarks = bookmarks();
    for (const KBookmark &bm : bmarks) {
        DocumentViewport vp(bm.url().fragment(QUrl::FullyDecoded));
        if (vp.isValid() && vp.pageNumber == page) {
            return bm;
        }
    }
    return KBookmark();
}

KBookmark BookmarkManager::bookmark(const DocumentViewport &viewport) const
{
    if (!viewport.isValid() || !isBookmarked(viewport.pageNumber)) {
        return KBookmark();
    }

    KBookmarkGroup thebg;
    QHash<QUrl, QString>::iterator it = d->bookmarkFind(d->url, false, &thebg);
    if (it == d->knownFiles.end()) {
        return KBookmark();
    }

    for (KBookmark bm = thebg.first(); !bm.isNull(); bm = thebg.next(bm)) {
        if (bm.isSeparator() || bm.isGroup()) {
            continue;
        }

        DocumentViewport vp(bm.url().fragment(QUrl::FullyDecoded));
        if (documentViewportFuzzyCompare(vp, viewport)) {
            return bm;
        }
    }

    return KBookmark();
}

void BookmarkManager::save() const
{
    d->manager.emitChanged();
    Q_EMIT const_cast<BookmarkManager *>(this)->saved();
}

QHash<QUrl, QString>::iterator BookmarkManager::Private::bookmarkFind(const QUrl &url, bool doCreate, KBookmarkGroup *result)
{
    QHash<QUrl, QString>::iterator it = knownFiles.find(url);
    if (it == knownFiles.end()) {
        // if the url we want to add a new entry for is not in the hash of the
        // known files, then first try to find the file among the top-level
        // "folder" names
        bool found = false;
        KBookmarkGroup root = manager.root();
        for (KBookmark bm = root.first(); !found && !bm.isNull(); bm = root.next(bm)) {
            if (bm.isSeparator() || !bm.isGroup()) {
                continue;
            }

            QUrl tmpurl(urlForGroup(bm));
            if (tmpurl == url) {
                // got it! place it the hash of known files
                KBookmarkGroup bg = bm.toGroup();
                it = knownFiles.insert(url, bg.address());
                found = true;
                if (result) {
                    *result = bg;
                }
                break;
            }
        }
        if (!found && doCreate) {
            // folder not found :(
            // then, in a single step create a new folder and add it in our cache :)
            QString purl = url.isLocalFile() ? url.toLocalFile() : url.toDisplayString();
            KBookmarkGroup newbg = root.createNewFolder(purl);
            newbg.setUrl(url);
            it = knownFiles.insert(url, newbg.address());
            if (result) {
                *result = newbg;
            }
        }
    } else if (result) {
        const KBookmark bm = manager.findByAddress(it.value());
        Q_ASSERT(bm.isGroup());
        *result = bm.toGroup();
    }
    return it;
}

void BookmarkManager::addBookmark(int page)
{
    if (isBookmarked(page)) {
        return;
    }

    DocumentViewport vp;
    vp.pageNumber = page;
    addBookmark(vp);
}

void BookmarkManager::addBookmark(const DocumentViewport &vp)
{
    addBookmark(d->url, vp);
}

bool BookmarkManager::addBookmark(const QUrl &documentUrl, const Okular::DocumentViewport &vp, const QString &title)
{
    if (!documentUrl.isValid() || !vp.isValid()) {
        return false;
    }

    if (vp.pageNumber < 0 || vp.pageNumber >= d->document->m_pagesVector.count()) {
        return false;
    }

    const QUrl referurl = mostCanonicalUrl(documentUrl);

    KBookmarkGroup thebg;
    QHash<QUrl, QString>::iterator it = d->bookmarkFind(referurl, true, &thebg);
    Q_ASSERT(it != d->knownFiles.end());

    int count = 0; // Number of bookmarks in the current page
    bool found = false;
    // Check if the bookmark already exists
    for (KBookmark bm = thebg.first(); !found && !bm.isNull(); bm = thebg.next(bm)) {
        if (bm.isSeparator() || bm.isGroup()) {
            continue;
        }

        DocumentViewport bmViewport(bm.url().fragment(QUrl::FullyDecoded));
        if (bmViewport.isValid() && bmViewport.pageNumber == vp.pageNumber) {
            ++count;

            if (documentViewportFuzzyCompare(bmViewport, vp)) {
                found = true;
            }
        }
    }

    if (found) {
        return false;
    }

    QString newtitle;
    if (title.isEmpty()) {
        // if we have no title specified for the new bookmark, then give it the
        // name '#p' where p is the page number where the bookmark is located.
        // if there's more than one bookmark per page, give the name '#p-n'
        // where n is the index of this bookmark among the ones of its page.
        const QString pageLabel = d->document->m_parent->page(vp.pageNumber)->label();
        newtitle = QStringLiteral("#");
        if (pageLabel.isNull()) {
            newtitle += QString::number(vp.pageNumber + 1);
        } else {
            newtitle += pageLabel;
        }
        if (count > 0) {
            newtitle += QStringLiteral("-") + QString::number(count);
        }
    } else {
        newtitle = title;
    }

    QUrl newurl = referurl;
    newurl.setFragment(vp.toString(), QUrl::DecodedMode);
    thebg.addBookmark(newtitle, newurl, QString());
    if (referurl == d->document->m_url) {
        d->urlBookmarks[vp.pageNumber]++;
        foreachObserver(notifyPageChanged(vp.pageNumber, DocumentObserver::Bookmark));
    }
    d->manager.emitChanged(thebg);
    return true;
}

void BookmarkManager::removeBookmark(int page)
{
    if (page >= 0 && page < (int)d->document->m_pagesVector.count()) {
        if (removePageBookmark(page))
            foreachObserver(notifyPageChanged(page, DocumentObserver::Bookmark));
    }
}

void BookmarkManager::removeBookmark(const DocumentViewport &vp)
{
    int page = vp.pageNumber;
    if (page >= 0 && page < d->document->m_pagesVector.count()) {
        removeBookmark(d->url, bookmark(vp));
    }
}

void BookmarkManager::renameBookmark(KBookmark *bm, const QString &newName)
{
    KBookmarkGroup thebg;
    QHash<QUrl, QString>::iterator it = d->bookmarkFind(d->url, false, &thebg);
    Q_ASSERT(it != d->knownFiles.end());
    if (it == d->knownFiles.end()) {
        return;
    }

    bm->setFullText(newName);
    d->manager.emitChanged(thebg);
}

void BookmarkManager::renameBookmark(const QUrl &documentUrl, const QString &newName)
{
    if (!documentUrl.isValid()) {
        return;
    }

    const QUrl referurl = mostCanonicalUrl(documentUrl);

    KBookmarkGroup thebg;
    QHash<QUrl, QString>::iterator it = d->bookmarkFind(referurl, false, &thebg);
    Q_ASSERT(it != d->knownFiles.end());
    if (it == d->knownFiles.end()) {
        return;
    }

    thebg.setFullText(newName);
    d->manager.emitChanged(thebg);
}

QString BookmarkManager::titleForUrl(const QUrl &documentUrl) const
{
    KBookmarkGroup thebg;
    QHash<QUrl, QString>::iterator it = d->bookmarkFind(mostCanonicalUrl(documentUrl), false, &thebg);
    Q_ASSERT(it != d->knownFiles.end());

    return thebg.fullText();
}

int BookmarkManager::removeBookmark(const QUrl &documentUrl, const KBookmark &bm)
{
    if (!documentUrl.isValid() || bm.isNull() || bm.isGroup() || bm.isSeparator()) {
        return -1;
    }

    DocumentViewport vp(bm.url().fragment(QUrl::FullyDecoded));
    if (!vp.isValid()) {
        return -1;
    }

    const QUrl referurl = mostCanonicalUrl(documentUrl);

    KBookmarkGroup thebg;
    QHash<QUrl, QString>::iterator it = d->bookmarkFind(referurl, false, &thebg);
    if (it == d->knownFiles.end()) {
        return -1;
    }

    thebg.deleteBookmark(bm);

    if (referurl == d->document->m_url) {
        d->urlBookmarks[vp.pageNumber]--;
        foreachObserver(notifyPageChanged(vp.pageNumber, DocumentObserver::Bookmark));
    }
    d->manager.emitChanged(thebg);

    return vp.pageNumber;
}

void BookmarkManager::removeBookmarks(const QUrl &documentUrl, const KBookmark::List &list)
{
    if (!documentUrl.isValid() || list.isEmpty()) {
        return;
    }

    const QUrl referurl = mostCanonicalUrl(documentUrl);

    KBookmarkGroup thebg;
    QHash<QUrl, QString>::iterator it = d->bookmarkFind(referurl, false, &thebg);
    if (it == d->knownFiles.end()) {
        return;
    }

    const QHash<int, int> oldUrlBookmarks = d->urlBookmarks;
    bool deletedAny = false;
    for (const KBookmark &bm : list) {
        if (bm.parentGroup() == thebg) {
            thebg.deleteBookmark(bm);
            deletedAny = true;

            DocumentViewport vp(bm.url().fragment(QUrl::FullyDecoded));
            if (referurl == d->document->m_url) {
                d->urlBookmarks[vp.pageNumber]--;
            }
        }
    }

    if (referurl == d->document->m_url) {
        for (int i = 0; i < qMax(oldUrlBookmarks.size(), d->urlBookmarks.size()); i++) {
            bool oldContains = oldUrlBookmarks.contains(i) && oldUrlBookmarks[i] > 0;
            bool curContains = d->urlBookmarks.contains(i) && d->urlBookmarks[i] > 0;

            if (oldContains != curContains) {
                foreachObserver(notifyPageChanged(i, DocumentObserver::Bookmark));
            } else if (oldContains && oldUrlBookmarks[i] != d->urlBookmarks[i]) {
                foreachObserver(notifyPageChanged(i, DocumentObserver::Bookmark));
            }
        }
    }
    if (deletedAny) {
        d->manager.emitChanged(thebg);
    }
}

QList<QAction *> BookmarkManager::actionsForUrl(const QUrl &documentUrl) const
{
    const QUrl url = mostCanonicalUrl(documentUrl);
    QList<QAction *> ret;
    KBookmarkGroup group = d->manager.root();
    for (KBookmark bm = group.first(); !bm.isNull(); bm = group.next(bm)) {
        if (!bm.isGroup() || urlForGroup(bm) != url) {
            continue;
        }

        KBookmarkGroup bmGroup = bm.toGroup();
        for (KBookmark b = bmGroup.first(); !b.isNull(); b = bmGroup.next(b)) {
            if (b.isSeparator() || b.isGroup()) {
                continue;
            }

            ret.append(new OkularBookmarkAction(DocumentViewport(b.url().fragment(QUrl::FullyDecoded)), b, d, nullptr));
        }
        break;
    }
    std::sort(ret.begin(), ret.end(), okularBookmarkActionLessThan);
    return ret;
}

void BookmarkManager::setUrl(const QUrl &url)
{
    d->url = mostCanonicalUrl(url);
    d->urlBookmarks.clear();
    KBookmarkGroup thebg;
    QHash<QUrl, QString>::iterator it = d->bookmarkFind(d->url, false, &thebg);
    if (it != d->knownFiles.end()) {
        for (KBookmark bm = thebg.first(); !bm.isNull(); bm = thebg.next(bm)) {
            if (bm.isSeparator() || bm.isGroup()) {
                continue;
            }

            DocumentViewport vp(bm.url().fragment(QUrl::FullyDecoded));
            if (!vp.isValid()) {
                continue;
            }

            d->urlBookmarks[vp.pageNumber]++;
        }
    }
}

bool BookmarkManager::removePageBookmark(int page)
{
    KBookmarkGroup thebg;
    QHash<QUrl, QString>::iterator it = d->bookmarkFind(d->url, false, &thebg);
    if (it == d->knownFiles.end()) {
        return false;
    }

    bool found = false;
    for (KBookmark bm = thebg.first(); !found && !bm.isNull(); bm = thebg.next(bm)) {
        if (bm.isSeparator() || bm.isGroup()) {
            continue;
        }

        DocumentViewport vp(bm.url().fragment(QUrl::FullyDecoded));
        if (vp.isValid() && vp.pageNumber == page) {
            found = true;
            thebg.deleteBookmark(bm);
            d->urlBookmarks[page]--;
            d->manager.emitChanged(thebg);
        }
    }
    return found;
}

bool BookmarkManager::isBookmarked(int page) const
{
    return d->urlBookmarks.contains(page) && d->urlBookmarks[page] > 0;
}

bool BookmarkManager::isBookmarked(const DocumentViewport &viewport) const
{
    KBookmark bm = bookmark(viewport);

    return !bm.isNull();
}

KBookmark BookmarkManager::nextBookmark(const DocumentViewport &viewport) const
{
    KBookmark::List bmarks = bookmarks();
    std::sort(bmarks.begin(), bmarks.end(), bookmarkLessThan);

    KBookmark nextBm;
    for (const KBookmark &bm : std::as_const(bmarks)) {
        DocumentViewport vp(bm.url().fragment(QUrl::FullyDecoded));
        if (viewport < vp) {
            nextBm = bm;
            break;
        }
    }

    return nextBm;
}

KBookmark BookmarkManager::previousBookmark(const DocumentViewport &viewport) const
{
    KBookmark::List bmarks = bookmarks();
    std::sort(bmarks.begin(), bmarks.end(), bookmarkLessThan);

    KBookmark prevBm;
    for (KBookmark::List::const_iterator it = bmarks.constEnd(); it != bmarks.constBegin(); --it) {
        KBookmark bm = *(it - 1);
        DocumentViewport vp(bm.url().fragment(QUrl::FullyDecoded));
        if (vp < viewport) {
            prevBm = bm;
            break;
        }
    }

    return prevBm;
}

#undef foreachObserver
#undef foreachObserverD

#include "bookmarkmanager.moc"

/* kate: replace-tabs on; indent-width 4; */
