// SPDX-FileCopyrightText: 2021 Noah Davis <noahadvs@gmail.com>
// SPDX-License-Identifier: LGPL-2.1-or-later

#include "zoomcombobox.h"
#include "zoomcombobox_p.h"

#include <KLocalizedString>
#include <QAbstractItemView>
#include <QAction>
#include <QLineEdit>
#include <QSignalBlocker>
#include <QWheelEvent>

#include <cmath>

bool fuzzyEqual(qreal a, qreal b)
{
    return (qFuzzyIsNull(a) && qFuzzyIsNull(b)) || qFuzzyCompare(a, b);
}

bool fuzzyLessEqual(qreal a, qreal b)
{
    return fuzzyEqual(a, b) || a < b;
}

bool fuzzyGreaterEqual(qreal a, qreal b)
{
    return fuzzyEqual(a, b) || a > b;
}

using namespace Gwenview;

struct LineEditSelectionKeeper {
    LineEditSelectionKeeper(QLineEdit *lineEdit)
        : m_lineEdit(lineEdit)
    {
        Q_ASSERT(m_lineEdit);
        m_cursorPos = m_lineEdit->cursorPosition();
    }

    ~LineEditSelectionKeeper()
    {
        m_lineEdit->end(false);
        m_lineEdit->cursorBackward(true, m_lineEdit->text().length() - m_cursorPos);
    }

private:
    QLineEdit *m_lineEdit;
    int m_cursorPos;
};

ZoomValidator::ZoomValidator(qreal minimum, qreal maximum, ZoomComboBox *q, ZoomComboBoxPrivate *d, QWidget *parent)
    : QValidator(parent)
    , m_minimum(minimum)
    , m_maximum(maximum)
    , m_zoomComboBox(q)
    , m_zoomComboBoxPrivate(d)
{
}

ZoomValidator::~ZoomValidator() noexcept = default;

qreal ZoomValidator::minimum() const
{
    return m_minimum;
}

void ZoomValidator::setMinimum(const qreal minimum)
{
    if (fuzzyEqual(m_minimum, minimum)) {
        return;
    }
    m_minimum = minimum;
    Q_EMIT changed();
}

qreal ZoomValidator::maximum() const
{
    return m_maximum;
}

void ZoomValidator::setMaximum(const qreal maximum)
{
    if (fuzzyEqual(m_maximum, maximum)) {
        return;
    }
    m_maximum = maximum;
    Q_EMIT changed();
}

QValidator::State ZoomValidator::validate(QString &input, int &pos) const
{
    Q_UNUSED(pos)
    if (m_zoomComboBox->findText(input, Qt::MatchFixedString) > -1) {
        return QValidator::Acceptable;
    }

    QString copy = input.trimmed();
    copy.remove(locale().groupSeparator());
    copy.remove(locale().percent());
    const bool startsWithNumber = copy.constBegin()->isNumber();

    if (startsWithNumber || copy.isEmpty()) {
        return QValidator::Intermediate;
    }

    QValidator::State state;
    bool ok = false;
    int value = locale().toInt(copy, &ok);
    if (!ok || value < std::ceil(m_minimum * 100) || value > std::floor(m_maximum * 100)) {
        state = QValidator::Intermediate;
    } else {
        state = QValidator::Acceptable;
    }
    return state;
}

ZoomComboBoxPrivate::ZoomComboBoxPrivate(ZoomComboBox *q)
    : q_ptr(q)
    , validator(new ZoomValidator(0, 0, q, this, q))
{
}

ZoomComboBox::ZoomComboBox(QWidget *parent)
    : QComboBox(parent)
    , d_ptr(new ZoomComboBoxPrivate(this))
{
    Q_D(ZoomComboBox);

    d->validator->setObjectName(QLatin1String("zoomValidator"));
    setValidator(d->validator);

    setEditable(true);
    setInsertPolicy(QComboBox::NoInsert);
    // QLocale::percent() will return a QString in Qt 6.
    // Qt encourages using QString(locale().percent()) in QLocale documentation.
    const int percentLength = QString(locale().percent()).length();
    setMinimumContentsLength(locale().toString(9999).length() + percentLength);

    connect(lineEdit(), &QLineEdit::textEdited, this, [this, d](const QString &text) {
        const bool startsWithNumber = text.constBegin()->isNumber();
        int matchedIndex = -1;
        if (startsWithNumber) {
            matchedIndex = findText(text, Qt::MatchFixedString);
        } else {
            // check if there is more than 1 match
            for (int i = 0, n = count(); i < n; ++i) {
                if (itemText(i).startsWith(text, Qt::CaseInsensitive)) {
                    if (matchedIndex != -1) {
                        // there is more than 1 match
                        return;
                    }
                    matchedIndex = i;
                }
            }
        }
        if (matchedIndex != -1) {
            LineEditSelectionKeeper selectionKeeper(lineEdit());
            updateCurrentIndex();
            if (matchedIndex == currentIndex()) {
                updateDisplayedText();
            } else {
                activateAndChangeZoomTo(matchedIndex);
            }
        } else if (startsWithNumber) {
            bool ok = false;
            qreal value = valueFromText(text, &ok);
            if (ok && value > 0 && fuzzyLessEqual(value, maximum())) {
                // emulate autocompletion for valid values that aren't predefined
                while (value < minimum()) {
                    value *= 10;
                    if (value > maximum()) {
                        // autocompletion cannot be emulated for this value
                        return;
                    }
                }
                LineEditSelectionKeeper selectionKeeper(lineEdit());
                if (fuzzyEqual(value, d->value)) {
                    updateDisplayedText();
                } else {
                    d->lastCustomZoomValue = value;
                    activateAndChangeZoomTo(-1);
                }
            }
        }
    });
    connect(this, qOverload<int>(&ZoomComboBox::highlighted), this, &ZoomComboBox::changeZoomTo);
    view()->installEventFilter(this);
    connect(this, qOverload<int>(&ZoomComboBox::activated), this, &ZoomComboBox::activateAndChangeZoomTo);
}

ZoomComboBox::~ZoomComboBox() noexcept = default;

void ZoomComboBox::setActions(QAction *zoomToFitAction, QAction *zoomToFillAction, QAction *actualSizeAction)
{
    Q_D(ZoomComboBox);
    d->setActions(zoomToFitAction, zoomToFillAction, actualSizeAction);

    connect(zoomToFitAction, &QAction::toggled, this, &ZoomComboBox::updateDisplayedText);
    connect(zoomToFillAction, &QAction::toggled, this, &ZoomComboBox::updateDisplayedText);
}

void ZoomComboBoxPrivate::setActions(QAction *zoomToFitAction, QAction *zoomToFillAction, QAction *actualSizeAction)
{
    Q_Q(ZoomComboBox);
    q->clear();
    // Add items reversed, so there's a consistent behavior between zoom slider and this combo box
    q->addItem(actualSizeAction->iconText(), QVariant::fromValue(actualSizeAction));
    q->addItem(zoomToFillAction->iconText(), QVariant::fromValue(zoomToFillAction));
    q->addItem(zoomToFitAction->iconText(), QVariant::fromValue(zoomToFitAction));

    mZoomToFitAction = zoomToFitAction;
    mZoomToFillAction = zoomToFillAction;
    mActualSizeAction = actualSizeAction;
}

qreal ZoomComboBox::value() const
{
    Q_D(const ZoomComboBox);
    return d->value;
}

void ZoomComboBox::setValue(qreal value)
{
    Q_D(ZoomComboBox);
    d->value = value;

    updateDisplayedText();
}

qreal ZoomComboBox::minimum() const
{
    Q_D(const ZoomComboBox);
    return d->validator->minimum();
}

void ZoomComboBox::setMinimum(qreal minimum)
{
    Q_D(ZoomComboBox);
    if (fuzzyEqual(this->minimum(), minimum)) {
        return;
    }
    d->validator->setMinimum(minimum);
    setValue(qMax(minimum, d->value));
    // Generate zoom presets below 100%
    // FIXME: combobox value gets reset to last index value when this code runs
    const int zoomToFillActionIndex = findData(QVariant::fromValue(d->mZoomToFillAction));
    const int actualSizeActionIndex = findData(QVariant::fromValue(d->mActualSizeAction));
    for (int i = actualSizeActionIndex - 1; i > zoomToFillActionIndex; --i) {
        removeItem(i);
    }
    qreal value = minimum * 2; // The minimum zoom value itself is already available through "fit".
    for (int i = zoomToFillActionIndex; value < 1.0; --i) {
        insertItem(i, textFromValue(value), QVariant::fromValue(value));
        value *= 2;
    }
}

qreal ZoomComboBox::maximum() const
{
    Q_D(const ZoomComboBox);
    return d->validator->maximum();
}

void ZoomComboBox::setMaximum(qreal maximum)
{
    Q_D(ZoomComboBox);
    if (fuzzyEqual(this->maximum(), maximum)) {
        return;
    }
    d->validator->setMaximum(maximum);
    setValue(qMin(d->value, maximum));
    // Generate zoom presets above 100%
    // NOTE: This probably has the same problem as setMinimum(),
    // but the problem is never encountered since max zoom doesn't actually change
    const int actualSizeActionIndex = findData(QVariant::fromValue(d->mActualSizeAction));
    for (int i = actualSizeActionIndex - 1; i >= 0; i--) {
        removeItem(i);
    }
    qreal value = 2.0;
    while (value < maximum) {
        insertItem(0, textFromValue(value), QVariant::fromValue(value));
        value *= 2;
    }
    if (fuzzyGreaterEqual(value, maximum)) {
        insertItem(0, textFromValue(maximum), QVariant::fromValue(maximum));
    }
}

qreal ZoomComboBox::valueFromText(const QString &text, bool *ok) const
{
    Q_D(const ZoomComboBox);

    const QLocale l = locale();
    QString s = text;
    s.remove(l.groupSeparator());

    if (s.endsWith(l.percent())) {
        s = s.chopped(1);
    }

    if (s.startsWith(l.percent())) {
        s = s.remove(0, 1);
    }

    return l.toDouble(s, ok) / 100.0;
}

QString ZoomComboBox::textFromValue(const qreal value) const
{
    Q_D(const ZoomComboBox);

    QLocale l = locale();
    l.setNumberOptions(QLocale::OmitGroupSeparator);

    QString formattedValue = l.toString(qRound(value * 100));
    return i18nc("Percent value", "%1%", formattedValue);
}

void ZoomComboBox::updateDisplayedText()
{
    Q_D(ZoomComboBox);
    if (d->mZoomToFitAction->isChecked()) {
        lineEdit()->setText(d->mZoomToFitAction->iconText());
    } else if (d->mZoomToFillAction->isChecked()) {
        lineEdit()->setText(d->mZoomToFillAction->iconText());
    } else if (d->mActualSizeAction->isChecked()) {
        lineEdit()->setText(d->mActualSizeAction->iconText());
    } else {
        lineEdit()->setText(textFromValue(d->value));
    }
}

void Gwenview::ZoomComboBox::showPopup()
{
    updateCurrentIndex();

    // We don't want to emit a QComboBox::highlighted event just because the popup is opened.
    const QSignalBlocker blocker(this);
    QComboBox::showPopup();
}

bool ZoomComboBox::eventFilter(QObject *watched, QEvent *event)
{
    if (watched == view()) {
        switch (event->type()) {
        case QEvent::Hide: {
            Q_D(ZoomComboBox);
            changeZoomTo(d->lastSelectedIndex);
            break;
        }
        case QEvent::ShortcutOverride: {
            if (view()->isVisibleTo(this)) {
                auto keyEvent = static_cast<QKeyEvent *>(event);
                if (keyEvent->key() == Qt::Key_Escape) {
                    event->accept();
                }
            }
            break;
        }
        default:
            break;
        }
    }

    return QComboBox::eventFilter(watched, event);
}

void ZoomComboBox::focusOutEvent(QFocusEvent *event)
{
    Q_D(ZoomComboBox);
    // Should the user have started typing a custom value
    // that was out of our range, then we have a temporary
    // state that is illegal. This is needed to allow a user
    // to type a zoom with multiple keystrokes, but when the
    // user leaves focus we should reset to the last known 'good'
    // zoom value.
    if (d->lastSelectedIndex == -1)
        setValue(d->lastCustomZoomValue);

    QComboBox::focusOutEvent(event);
}

void ZoomComboBox::keyPressEvent(QKeyEvent *event)
{
    switch (event->key()) {
    case Qt::Key_Down:
    case Qt::Key_Up:
    case Qt::Key_PageDown:
    case Qt::Key_PageUp: {
        updateCurrentIndex();
        if (currentIndex() != -1) {
            break;
        }
        moveCurrentIndex(event->key() == Qt::Key_Down || event->key() == Qt::Key_PageDown);
        return;
    }
    default:
        break;
    }

    QComboBox::keyPressEvent(event);
}

void ZoomComboBox::wheelEvent(QWheelEvent *event)
{
    updateCurrentIndex();
    if (currentIndex() != -1) {
        // Everything should work as expected.
        QComboBox::wheelEvent(event);
        return;
    }

    moveCurrentIndex(event->angleDelta().y() < 0);
}

void ZoomComboBox::updateCurrentIndex()
{
    Q_D(ZoomComboBox);
    const int count = this->count();
    if (d->mZoomToFitAction->isChecked()) {
        setCurrentIndex(count - 1); // 'zoom to fit' is always last
        d->lastSelectedIndex = count - 1;
    } else if (d->mZoomToFillAction->isChecked()) {
        setCurrentIndex(count - 2);
        d->lastSelectedIndex = count - 2; // 'zoom to fill' is always last - 1
    } else if (d->mActualSizeAction->isChecked()) {
        const int actualSizeActionIndex = findData(QVariant::fromValue(d->mActualSizeAction));
        setCurrentIndex(actualSizeActionIndex);
        d->lastSelectedIndex = actualSizeActionIndex;
    } else {
        // Now is a good time to save the zoom value that was selected before the user changes it through the popup.
        d->lastCustomZoomValue = d->value;

        // Highlight the correct index if the current zoom value exists as an option in the popup.
        // If it doesn't exist, it is set to -1.
        d->lastSelectedIndex = findText(textFromValue(d->value));
        setCurrentIndex(d->lastSelectedIndex);
    }
}

void ZoomComboBox::moveCurrentIndex(bool moveUp)
{
    // There is no exact match for the current zoom value in the
    // ComboBox. We need to find the closest matches, so scrolling
    // works as expected.
    Q_D(ZoomComboBox);

    int newIndex;
    if (moveUp) {
        // switch to a larger item
        newIndex = count() - 1;
        for (int i = 2; i < newIndex; ++i) {
            const QVariant data = itemData(i);
            qreal value;
            if (data.value<QAction *>() == d->mActualSizeAction) {
                value = 1;
            } else {
                value = data.value<qreal>();
            }
            if (value > d->value) {
                newIndex = i;
                break;
            }
        }
    } else {
        // switch to a smaller item
        newIndex = 1;
        for (int i = count() - 1; i > newIndex; --i) {
            const QVariant data = itemData(i);
            qreal value;
            if (data.value<QAction *>() == d->mActualSizeAction) {
                value = 1;
            } else {
                value = data.value<qreal>();
            }
            if (value < d->value) {
                newIndex = i;
                break;
            }
        }
    }
    setCurrentIndex(newIndex);
    Q_EMIT activated(newIndex);
}

void ZoomComboBox::changeZoomTo(int index)
{
    if (index < 0) {
        Q_D(ZoomComboBox);
        Q_EMIT zoomChanged(d->lastCustomZoomValue);
        return;
    }

    QVariant itemData = this->itemData(index);
    auto action = itemData.value<QAction *>();
    if (action) {
        if (!action->isCheckable() || !action->isChecked()) {
            action->trigger();
        }
    } else if (itemData.canConvert(QMetaType::QReal)) {
        Q_EMIT zoomChanged(itemData.toReal());
    }
}

void ZoomComboBox::activateAndChangeZoomTo(int index)
{
    Q_D(ZoomComboBox);
    d->lastSelectedIndex = index;

    // The user has explicitly selected this zoom value so we
    // remember it the same way as if they had typed it themselves.
    QVariant itemData = this->itemData(index);
    if (!itemData.value<QAction *>() && itemData.canConvert(QMetaType::QReal)) {
        d->lastCustomZoomValue = itemData.toReal();
    }

    changeZoomTo(index);
}

#include "moc_zoomcombobox.cpp"

#include "moc_zoomcombobox_p.cpp"
