/*
    SPDX-FileCopyrightText: 2016-2022 Thomas Baumgart <tbaumgart@kde.org>
    SPDX-License-Identifier: GPL-2.0-or-later
*/

#include "kmymoneydateedit.h"

// ----------------------------------------------------------------------------
// QT Includes

#include <QApplication>
#include <QClipboard>
#include <QDate>
#include <QDateTimeEdit>
#include <QKeyEvent>
#include <QLineEdit>
#include <QMimeData>

// ----------------------------------------------------------------------------
// KDE Includes

#include <KLocalizedString>
#include <KMessageBox>

KMyMoneyDateEditSettings* KMyMoneyDateEditSettings::instance()
{
    static KMyMoneyDateEditSettings* m_instance = nullptr;

    if (m_instance == nullptr) {
        m_instance = new KMyMoneyDateEditSettings;
    }
    return m_instance;
}

class KMyMoneyDateEditPrivate
{
public:
    KMyMoneyDateEditPrivate(KMyMoneyDateEdit* qq)
        : q(qq)
        , m_originalDay(0)
        , m_emptyDateAllowed(false)
        , m_dateValidity(false)
        , m_lastKeyPressWasEscape(false)
        , m_firstFocusIn(true)
        , m_settings(KMyMoneyDateEditSettings::instance())
    {
        int sectionIndex(0);
        int sectionSize(0);
        bool lastWasDelimiter(false);
        bool lastDelimiterAdded(false);
        m_sections.resize(3);
        const auto format(q->locale().dateFormat(QLocale::ShortFormat).toLower());

        for (int pos = 0; (pos < format.length()) && (sectionIndex < 3); ++pos) {
            const auto ch = format[pos];
            ++sectionSize;
            if (ch == QLatin1Char('d')) {
                m_sections[sectionIndex] = QDateTimeEdit::DaySection;
                lastWasDelimiter = false;
            } else if (ch == QLatin1Char('m')) {
                m_sections[sectionIndex] = QDateTimeEdit::MonthSection;
                lastWasDelimiter = false;
            } else if (ch == QLatin1Char('y')) {
                m_sections[sectionIndex] = QDateTimeEdit::YearSection;
                lastWasDelimiter = false;
            } else {
                if (!lastWasDelimiter) {
                    ++sectionIndex;
                    sectionSize = 0;
                }
                // in case we detect multiple delimiters in row, we
                // only keep the last one. E.g. in "dd. MMM. yyyy" the
                // space is the delimiter and the dot is a fill character
                if (lastWasDelimiter && lastDelimiterAdded) {
                    const auto invalidDelim = m_validDelims.at(m_validDelims.length() - 1);
                    if (!m_invalidDelims.contains(invalidDelim)) {
                        m_invalidDelims.append(invalidDelim);
                    }
                    m_validDelims.remove(m_validDelims.length() - 1, 1);
                    lastDelimiterAdded = false;
                }

                // only add delimiters between after first and second section
                // Some format may have another delimiter after the third
                // section which we don't care for here (e.g. Hungarian)
                if (sectionIndex < 3) {
                    if (!m_validDelims.contains(ch)) {
                        m_validDelims.append(ch);
                        lastDelimiterAdded = true;
                    } else {
                        lastDelimiterAdded = false;
                    }
                }

                if (!lastWasDelimiter) {
                    if (sectionSize > 2) {
                        /// @todo special handling for non-numeric fields
                    }
                    lastWasDelimiter = true;
                }
            }
        }
    }

    /**
     * Returns @c true if the character @a ch is a
     * valid delimiter otherwise @c false.
     */
    bool isValidDelimiter(const QChar& ch) const
    {
        return m_validDelims.contains(ch);
    }

    /**
     * Returns the section that the cursor currently
     * resides in
     */
    QDateTimeEdit::Section sectionByCursorPos() const
    {
        const auto text(q->lineEdit()->text());
        const auto pos(q->lineEdit()->cursorPosition());
        int sectionIndex(0);

        for (int idx(0); idx < pos; ++idx) {
            if (isValidDelimiter(text[idx]) && (sectionIndex < (m_sections.size() - 1))) {
                ++sectionIndex;
            }
        }
        return m_sections[sectionIndex];
    }

    /**
     * Returns the location of the @a section in
     * the date string
     */
    int partBySection(QDateTimeEdit::Section section) const
    {
        for (int idx(0); idx < m_sections.size(); ++idx) {
            if (m_sections[idx] == section) {
                return idx;
            }
        }
        return -1;
    }

    /**
     * Returns @c true if the character at position
     * @a pos is a delimiter, @c false otherwise.
     */
    bool isDelimiterAtPos(int pos) const
    {
        if (pos < 0 || pos >= q->lineEdit()->text().length()) {
            return false;
        }
        return isValidDelimiter(q->lineEdit()->text().at(pos));
    }

    /**
     * Dissects the current input into the individual parts
     * and returns them as a vector.
     */
    QVector<QString> editParts() const
    {
        const auto text(q->lineEdit()->text());
        QVector<QString> parts(3);
        int partIndex(0);
        for (int idx(0); idx < text.length(); ++idx) {
            const auto ch(text.at(idx));
            if (isValidDelimiter(ch)) {
                ++partIndex;
            } else if (!m_invalidDelims.contains(ch) && (partIndex < parts.size())) {
                parts[partIndex].append(ch);
            } else {
                parts.clear();
                break;
            }
        }
        return parts;
    }

    /**
     * Returns the QDate of the current input adjusted so that
     * - a missing year information is replaced by the current year
     * - a two digit year string is adjusted into the current century
     *
     * If any of the day or month part is missing, an invalid date is
     * returned. The same applies, when the current input represents
     * an invalid date (e.g. February 31st).
     *
     * In case the input is empty and empty input is allowed, a phony
     * date (which is valid but does not represent a real date) is
     * returned.
     */
    QDate fixupDate()
    {
        if (q->isNull() && m_emptyDateAllowed) {
            return QDate::fromJulianDay(1);
        }
        QVector<QString> parts(editParts());
        if (parts.isEmpty()) {
            return {};
        }

        const auto dayIndex = partBySection(QDateTimeEdit::DaySection);
        const auto monthIndex = partBySection(QDateTimeEdit::MonthSection);
        const auto yearIndex = partBySection(QDateTimeEdit::YearSection);

        // make sure we have a valid indeces into the vector
        if ((dayIndex == -1) || (monthIndex == -1) || (yearIndex == -1))
            return {};

        if (parts.at(dayIndex).isEmpty() || parts.at(monthIndex).isEmpty()) {
            return {};
        }

        if (parts.at(yearIndex).isEmpty()) {
            parts[yearIndex] = QString::number(QDate::currentDate().year());
        }
        if (parts.at(yearIndex).length() == 2) {
            parts[yearIndex] = QStringLiteral("%1%2").arg(QString::number(QDate::currentDate().year() / 100), parts[yearIndex]);
        }

        // convert the part of the month to a numeric value. in case
        // it is in alphanumeric form, it will result in zero which
        // is an invalid value for a month in a QDate object.
        int month = parts.at(monthIndex).toInt();
        if (month == 0) { // must be alphanumeric
            // now we have to solve the following problem: if the locale's
            // short date format uses a two digit year, we cannot use
            // QLocale::toDate() with a four digit year string. That
            // will result in an invalid date. Solution is to convert
            // the year part to the version that is expected by the locale,
            // create a string out of the parts and convert it to a QDate
            // using QLocale::toDate() and take the month of that date
            // for further processing.
            auto yearPart = parts.at(yearIndex);
            if (!q->locale().dateFormat(QLocale::ShortFormat).contains("yyyy")) {
                yearPart = yearPart.right(2);
            }
            // construct a valid date by appending the parts
            QString dateString;
            for (int part = 0; part < 3; ++part) {
                if (part > 0) {
                    dateString.append(m_validDelims[0]);
                }
                if (part == yearIndex) {
                    dateString.append(yearPart);
                } else {
                    dateString.append(parts[part]);
                }
            }
            month = q->locale().toDate(dateString, QLocale::ShortFormat).month();
        }
        return QDate(parts[yearIndex].toInt(), month, parts[dayIndex].toInt());
    }

    /**
     * Adjust the day of the @a date by the amount provided by @a delta
     * in days.
     */
    QDate adjustDay(QDate date, int delta)
    {
        return date.addDays(delta);
    }

    /**
     * Adjust the month of the @a date by the amount provided by @a delta
     * in months. Uses the original day. In case the original day is not within
     * the range of valid days for the resulting, post-adjustment month, the day
     * will be adjusted to the last day of that month.
     */
    QDate adjustMonth(QDate date, int delta)
    {
        QDate newDate = date.addMonths(delta);

        return QDate(newDate.year(), newDate.month(), qMin(m_originalDay, newDate.daysInMonth()));
    }

    /**
     * Adjust the year of the @a date by the amount provided by @a delta
     * in year. Uses the original day. In case the original day is not within
     * the range of valid days for the resulting, post-adjustment month, the day
     * will be adjusted to the last day of that month.
     */
    QDate adjustYear(QDate date, int delta)
    {
        QDate newDate = date.addYears(delta);

        return QDate(newDate.year(), newDate.month(), qMin(m_originalDay, newDate.daysInMonth()));
    }

    /**
     * Mark the text of the @a section as selected and place the
     * cursor behind the last character of that section.
     *
     * @sa QLineEdit::setSelection(), QLineEdit::setCursorPosition()
     */
    void selectSection(QDateEdit::Section section)
    {
        auto part(partBySection(section));
        const auto text(q->lineEdit()->text());
        const auto length(text.length());
        int start(-1);
        int end(-1);

        for (int pos = 0; pos < length; ++pos) {
            const auto isDelimiter(isValidDelimiter(text.at(pos)));
            if (part == 0) {
                if (start == -1) {
                    start = pos;
                }
                if (isDelimiter) {
                    end = pos;
                    break;
                }
            } else {
                if (isDelimiter) {
                    --part;
                }
            }
        }
        if (end == -1) {
            end = length;
        }
        q->lineEdit()->setCursorPosition(end);

        // in case the text field is empty, we cannot select anything
        if (start >= 0) {
            q->lineEdit()->setSelection(start, end - start);
        }
    }

    /**
     * In case the lineEdit object contains text and represents
     * a valid date, the @a section of the date is adjusted
     * by @a delta units. In case the @a section is
     * QDateEdit::NoSection, delta is added to the days
     * of the date including wrapping into the next/previous
     * month. This may also affect the year when the month
     * wraps.
     *
     * If the new date is valid and a disting section (day,
     * month or year) was selected, the text of this section
     * will be selected as part of the operation.
     */
    void adjustDate(int delta, QDateEdit::Section section)
    {
        if (!q->lineEdit()->text().isEmpty()) {
            auto date = fixupDate();
            if (date.isValid()) {
                switch (section) {
                case QDateEdit::MonthSection:
                    date = adjustMonth(date, delta);
                    break;
                case QDateEdit::YearSection:
                    date = adjustYear(date, delta);
                    break;
                case QDateEdit::DaySection:
                case QDateEdit::NoSection:
                default:
                    date = adjustDay(date, delta);
                    m_originalDay = date.day();
                    break;
                }
                if (date.isValid()) {
                    setDate(date);
                    if (section != QDateEdit::NoSection) {
                        selectSection(section);
                    }
                }
            }
        }
    }

    /**
     * Set the @a date into the widget. In case the empty date
     * is allowed and an invalid date is provided, the text of
     * the lineEdit widget will be cleared.
     */
    void setDate(const QDate& date)
    {
        q->KDateComboBox::setDate(date);
        if (m_emptyDateAllowed && !date.isValid()) {
            q->lineEdit()->clear();
        }
    }

    KMyMoneyDateEdit* q;
    QVector<QChar> m_validDelims;
    QVector<QChar> m_invalidDelims;
    QVector<QDateTimeEdit::Section> m_sections;
    int m_originalDay;
    bool m_emptyDateAllowed;
    bool m_dateValidity;
    bool m_lastKeyPressWasEscape;
    bool m_firstFocusIn;
    KMyMoneyDateEditSettings* m_settings;
};

KMyMoneyDateEdit::KMyMoneyDateEdit(QWidget* parent)
    : KDateComboBox(parent)
    , d(new KMyMoneyDateEditPrivate(this))
{
    setOptions(KDateComboBox::EditDate | KDateComboBox::SelectDate | KDateComboBox::DatePicker | KDateComboBox::WarnOnInvalid);

    connect(lineEdit(), &QLineEdit::textChanged, this, [&](const QString& text) {
        if (!text.isEmpty()) {
            const auto newDate(d->fixupDate());
            auto newDateIsValid = newDate.isValid() || (d->m_emptyDateAllowed && text.isEmpty());
            if (newDateIsValid != d->m_dateValidity) {
                Q_EMIT dateValidityChanged(newDate);
                d->m_dateValidity = newDateIsValid;
            }
        }
    });

    auto optionSettings = options();
    optionSettings &= ~WarnOnInvalid;
    setOptions(optionSettings);

    setDate(QDate::currentDate());
}

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

void KMyMoneyDateEdit::connectNotify(const QMetaMethod& signal)
{
    // Whenever a new object connects to our dateValidityChanged signal
    // we Q_EMIT the current status right away.
    if (signal == QMetaMethod::fromSignal(&KMyMoneyDateEdit::dateValidityChanged)) {
        const auto newDate(d->fixupDate());
        Q_EMIT dateValidityChanged(newDate);
    }
}

void KMyMoneyDateEdit::setDate(const QDate& date)
{
    // force sending out a single dateValidityChanged signal
    QSignalBlocker blocker(lineEdit());
    d->setDate(date);
    d->m_originalDay = date.day();
    const auto newDate = d->fixupDate();
    Q_EMIT dateValidityChanged(newDate);
    d->m_dateValidity = newDate.isValid();
}

void KMyMoneyDateEdit::setInitialSection(QDateEdit::Section section)
{
    switch (section) {
    case QDateEdit::DaySection:
    case QDateEdit::MonthSection:
    case QDateEdit::YearSection:
        break;
    default:
        section = QDateEdit::DaySection;
        break;
    }
    d->m_settings->initialSection = section;
}

void KMyMoneyDateEdit::setTabHandling(KMyMoneyDateEdit::TabHandling method)
{
    d->m_settings->tabHandling = method;
}

QDate KMyMoneyDateEdit::date() const
{
    const auto date(d->fixupDate());
    if (date.toJulianDay() == 1)
        return {};
    return date;
}

bool KMyMoneyDateEdit::isValid() const
{
    return d->fixupDate().isValid();
}

void KMyMoneyDateEdit::focusOutEvent(QFocusEvent* event)
{
    const auto reason = event->reason();
    if ((reason == Qt::TabFocusReason) || (reason == Qt::BacktabFocusReason)) {
        const auto date(d->fixupDate());
        if (!date.isValid()) {
            // if the editor is quit due to pressing ESC, we don't
            // show an error for having an invalid date.
            if (!d->m_lastKeyPressWasEscape) {
                KMessageBox::error(this, i18nc("@info", "The date you entered is invalid"));
                setFocus();
            }
        } else {
            // skip setting the date, if it is empty and this is allowed
            if (!d->m_emptyDateAllowed || (date != QDate::fromJulianDay(1))) {
                d->setDate(date);
                if (!lineEdit()->text().isEmpty())
                    KDateComboBox::focusOutEvent(event);
            } else {
                // prevent KDateComboBox from showing an error
                QComboBox::focusOutEvent(event);
            }
        }
    } else {
        // prevent KDateComboBox from showing an error
        QComboBox::focusOutEvent(event);
    }
    d->m_lastKeyPressWasEscape = false;
}

void KMyMoneyDateEdit::focusInEvent(QFocusEvent* event)
{
    KDateComboBox::focusInEvent(event);

    switch (event->reason()) {
    case Qt::TabFocusReason:
    case Qt::OtherFocusReason:
        d->selectSection(d->m_settings->initialSection);
        break;
    case Qt::BacktabFocusReason:
        if (d->m_settings->tabHandling == ChangeFocus) {
            d->selectSection(d->m_settings->initialSection);
        } else {
            d->selectSection(d->m_sections[d->m_sections.count() - 1]);
        }
        break;

    default:
        break;
    }
}

void KMyMoneyDateEdit::keyPressEvent(QKeyEvent* keyEvent)
{
    auto dropKey(false);
    const auto pos = lineEdit()->cursorPosition();
    const auto key = keyEvent->key();
    QDate date;
    d->m_lastKeyPressWasEscape = false;

    const auto keySeq = QKeySequence(keyEvent->modifiers() | keyEvent->key());

    if (keySeq.matches(QKeySequence(QKeySequence::Paste))) {
        const QClipboard* clipboard = QApplication::clipboard();
        const QMimeData* mimeData = clipboard->mimeData();
        if (mimeData->hasText()) {
            if (mimeData->text().length() > lineEdit()->selectedText().length()) {
                lineEdit()->selectAll();
            }
        } else {
            return; // only allow text to be pasted
        }
    }

    switch (key) {
    case Qt::Key_Down:
    case Qt::Key_Up:
        if (isValid()) {
            d->adjustDate(key == Qt::Key_Up ? 1 : -1, d->sectionByCursorPos());
        }
        break;

    case Qt::Key_Plus:
    case Qt::Key_Minus:
    case Qt::Key_Equal:
        if (isValid()) {
            d->adjustDate(key != Qt::Key_Minus ? 1 : -1, QDateEdit::DaySection);
        }
        break;

    case Qt::Key_BracketLeft:
    case Qt::Key_BraceLeft:
        if (isValid()) {
            d->adjustDate(-1, QDateEdit::MonthSection);
        }
        break;

    case Qt::Key_BracketRight:
    case Qt::Key_BraceRight:
        if (isValid()) {
            d->adjustDate(1, QDateEdit::MonthSection);
        }
        break;

    case Qt::Key_Colon:
    case Qt::Key_Semicolon:
        if (isValid()) {
            d->adjustDate(-1, QDateEdit::YearSection);
        }
        break;

    case Qt::Key_QuoteDbl:
    case Qt::Key_Apostrophe:
        if (isValid()) {
            d->adjustDate(1, QDateEdit::YearSection);
        }
        break;

    case Qt::Key_PageDown:
        date = d->fixupDate();
        break;
    case Qt::Key_PageUp:
        date = d->fixupDate();
        break;

    case Qt::Key_Enter:
    case Qt::Key_Return:
        date = d->fixupDate();
        if (!date.isValid()) {
            KMessageBox::error(this, i18nc("@info", "The date you entered is invalid"));
            keyEvent->accept();
            setFocus();
        } else {
            d->setDate(date);
            if (!lineEdit()->text().isEmpty()) {
                QComboBox::keyPressEvent(keyEvent);
            }
        }
        break;

    case Qt::Key_Backspace:
        // make sure the user does not enter two delimiters in a row
        // by simply removing the duplicate as well
        if (d->isDelimiterAtPos(pos) && d->isDelimiterAtPos(pos - 2)) {
            QComboBox::keyPressEvent(keyEvent);
        }
        QComboBox::keyPressEvent(keyEvent);
        break;

    case Qt::Key_Delete:
        // make sure the user does not enter two delimiters in a row
        // by simply removing the duplicate as well
        if (d->isDelimiterAtPos(pos - 1) && d->isDelimiterAtPos(pos + 1)) {
            QComboBox::keyPressEvent(keyEvent);
        }
        QComboBox::keyPressEvent(keyEvent);
        break;

    case Qt::Key_M:
        date = d->fixupDate();
        if (date.isValid()) {
            setDate(QDate(date.year(), date.month(), 1));
            d->selectSection(QDateEdit::MonthSection);
        }
        break;

    case Qt::Key_H:
        date = d->fixupDate();
        if (date.isValid()) {
            setDate(QDate(date.year(), date.month(), date.daysInMonth()));
            d->selectSection(QDateEdit::MonthSection);
        }
        break;

    case Qt::Key_Y:
        date = d->fixupDate();
        if (date.isValid()) {
            setDate(QDate(date.year(), 1, 1));
            d->selectSection(QDateEdit::YearSection);
        }
        break;

    case Qt::Key_R:
        date = d->fixupDate();
        if (date.isValid()) {
            setDate(QDate(date.year(), 12, 31));
            d->selectSection(QDateEdit::YearSection);
        }
        break;

    case Qt::Key_T:
        d->setDate(QDate::currentDate());
        Q_EMIT dateValidityChanged(QDate::currentDate());
        d->selectSection(d->m_settings->initialSection);
        break;

    default:
        if (keyEvent->text().length() > 0) {
            const auto ch(keyEvent->text().at(0));

            // prevent two delimiters in a row and simply
            // fake an overwrite and select the next section
            if (d->isValidDelimiter(ch)) {
                if (d->isDelimiterAtPos(pos)) {
                    const auto section(d->sectionByCursorPos());
                    const auto part(d->partBySection(section));
                    if (part < 2) {
                        d->selectSection(d->m_sections[part + 1]);
                    }
                    dropKey = true;
                }
            }
        }

        if (!dropKey) {
            const auto oldDate(d->fixupDate());
            const auto oldSection(d->sectionByCursorPos());
            QComboBox::keyPressEvent(keyEvent);
            const auto newDate(d->fixupDate());
            const auto newSection(d->sectionByCursorPos());

            if (newDate.isValid()) {
                if (!oldDate.isValid() || (oldDate.day() != newDate.day())) {
                    d->m_originalDay = newDate.day();
                }
                if ((keyEvent->modifiers() == Qt::NoModifier) && (oldSection != newSection)) {
                    d->selectSection(newSection);
                }
            }
        }
        break;

    case Qt::Key_Escape:
        d->m_lastKeyPressWasEscape = true;
        QComboBox::keyPressEvent(keyEvent);
        break;
    }
}

void KMyMoneyDateEdit::setDisplayFormat(QLocale::FormatType format)
{
    Q_UNUSED(format)
}

void KMyMoneyDateEdit::setAllowEmptyDate(bool emptyDateAllowed)
{
    d->m_emptyDateAllowed = emptyDateAllowed;
}

bool KMyMoneyDateEdit::event(QEvent* e)
{
    if (e->type() == QEvent::KeyPress) {
        if (d->m_settings->tabHandling == ChangeSection) {
            QKeyEvent* kev = static_cast<QKeyEvent*>(e);
            if (kev->key() == Qt::Key_Tab) {
                const auto nextIndex = d->partBySection(d->sectionByCursorPos()) + 1;
                if (nextIndex < d->m_sections.size()) {
                    d->selectSection(d->m_sections[nextIndex]);
                    return true;
                }
            } else if (kev->key() == Qt::Key_Backtab) {
                const auto nextIndex = d->partBySection(d->sectionByCursorPos()) - 1;
                if (nextIndex > -1) {
                    d->selectSection(d->m_sections[nextIndex]);
                    return true;
                }
            }
        }
    }
    return KDateComboBox::event(e);
}
