/*
 * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
 * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2 as
 * published by the Free Software Foundation.
 *
 * 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, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */

#include <QtTest>

#include <QAction>
#include <QApplication>
#include <QCloseEvent>
#include <QDir>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QMenu>
#include <QPushButton>
#include <QProgressBar>
#include <QFile>
#include <QSettings>
#include <QStandardPaths>
#include <QTemporaryDir>

#include <QTableView>
#include <QTimer>

#include "config.h"
#include "keymap.h"
#include "mainwindow.h"
#include "messageview.h"
#include "notmuchworker.h"
#include "threadlistmodel.h"

/// MainWindow is mostly wiring, and the parts that need a real database are
/// still verified manually. What is checked here is the action registry: the
/// bindings a user configures reach the QActions the menus and the keyboard
/// both read from, and no action is left unreachable.
class TestMainWindow : public QObject
{
    Q_OBJECT
private slots:
    void everyKnownActionIsRegistered();
    void everyRegisteredActionIsKnown();
    void everyActionHasAShortcut();
    void configuredBindingReachesTheAction();
    void cidPrefixesAreBangFree();
    void cidPrefixesAreDistinctPerMessage();
    void uiStateIsNotWrittenIntoTheUserConfig();
    void uiStateSurvivesARestart();
    void missingUiStateLeavesTheDefaults();
    void headerStateFromADifferentColumnLayoutIsDiscarded();
    void returnInTheQueryBarRunsTheQueryNotOpenThread();
    void markReadTimerRestartsRatherThanStacking();
    void markReadTimerIsNotArmedForAReadThread();
    void markReadCanBeDisabled();
    void pendingEditCountSurvivesAQuery();
    void aFailedSyncDoesNotClearThePendingCount();
    void closingWithNoPendingEditsDoesNotPrompt();
    void syncOnExitNeverClosesSilently();
    void selectAllIsBoundAndSelectsEveryRow();
    void aMultiRowSelectionDoesNotArmTheMarkReadTimer();
    void growingASelectionCancelsAnAlreadyArmedTimer();
    void collapsingBackToOneRowLoadsThatThreadAgain();
    void theStatusBarReportsAMultiRowSelection();
    void theThreadListOffersAContextMenu();
    void aSecondRowBlanksThePaneNotOnlyAThird();
    void aLocalSyncIsNotReportedAsABackgroundOne();
    void aLocalSyncsOwnLockIsNeverReportedAsBackground();
    void aSkippedLocalSyncStillReportsTheOtherRunFinishing();
    void anUnobservableLockTableLeavesTheSyncButtonUsable();
    void theSyncActionIsDisabledWhileABackgroundSyncHoldsTheLock();
    void escapeBlanksTheMessagePane();
    void deleteTogglesOnAnAlreadyDeletedThread();
    void deleteOnAMixedSelectionDeletesRatherThanSplittingIt();
    void aTransientStatusMessageExpires();
    void theSelectionCountIsStateAndDoesNotExpire();
    void anEditUndoneNettsBackToZero();
    void aDifferentTagOnTheSameMessageStillCounts();
    void anEditWithNoMessageIdsStillCounts();
    void anEditDuringABackgroundSyncIsNotSentYet();
    void aHeldEditIsSentWhenTheBackgroundSyncEnds();
    void aHeldEditCountsAsUnsynced();
    void anUnreadableLockTableStillSendsTheEdit();
    void aRejectedWriteKeepsEarlierUndoHistory();
};

void TestMainWindow::everyKnownActionIsRegistered()
{
    // KeyMap::knownActions() is what loadOverrides() validates config bindings
    // against. An action listed there but never registered means a user can
    // bind a key in qtmaildir.conf, get no warning, and have it do nothing.
    //
    // registeredActionNames() is now derived from the QActions themselves, so
    // this compares against what the window really installed.
    const Config config;
    MainWindow window(config);

    const QStringList known = KeyMap::knownActions();
    const QStringList registered = window.registeredActionNames();

    for (const QString &action : known) {
        QVERIFY2(registered.contains(action),
                 qPrintable(QStringLiteral("known action '%1' is never registered "
                                           "by MainWindow").arg(action)));
    }
}

void TestMainWindow::everyRegisteredActionIsKnown()
{
    // The reverse drift: an action MainWindow implements but KeyMap rejects.
    // The user would get "unknown action" for a binding that is really there.
    const Config config;
    MainWindow window(config);

    const QStringList known = KeyMap::knownActions();
    const QStringList registered = window.registeredActionNames();

    for (const QString &action : registered) {
        QVERIFY2(known.contains(action),
                 qPrintable(QStringLiteral("registered action '%1' is not in "
                                           "KeyMap::knownActions()").arg(action)));
    }
}

void TestMainWindow::everyActionHasAShortcut()
{
    // An action with no binding is unreachable from the keyboard. Every one
    // of them carries a default, so an empty shortcut means the default table
    // and the action list have drifted apart.
    const Config config;
    MainWindow window(config);

    for (const QString &name : window.registeredActionNames()) {
        const QAction *action = window.findChild<QAction *>(name);
        QVERIFY2(action, qPrintable(QStringLiteral("no QAction named '%1'").arg(name)));
        QVERIFY2(!action->shortcut().isEmpty(),
                 qPrintable(QStringLiteral("action '%1' has no shortcut").arg(name)));
    }
}

void TestMainWindow::configuredBindingReachesTheAction()
{
    // The whole point of [keys]: a user's override must end up on the QAction,
    // which is what both the keyboard and the menus read.
    QTemporaryDir dir;
    const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
    {
        QSettings s(path, QSettings::IniFormat);
        s.beginGroup(QStringLiteral("keys"));
        s.setValue(QStringLiteral("Ctrl+Alt+A"), QStringLiteral("archive"));
        s.endGroup();
    }

    // MainWindow reads its keymap from Config::defaultPath(), so point that
    // at the temporary file for this test.
    const QString previous = qEnvironmentVariable("XDG_CONFIG_HOME");
    QVERIFY(QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir"))));
    QVERIFY(QFile::copy(path, dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"))));
    qputenv("XDG_CONFIG_HOME", dir.path().toUtf8());

    {
        const Config config;
        MainWindow window(config);
        const QAction *archive =
            window.findChild<QAction *>(QStringLiteral("archive"));
        QVERIFY(archive);
        QCOMPARE(archive->shortcut(), QKeySequence(QStringLiteral("Ctrl+Alt+A")));
    }

    if (previous.isEmpty())
        qunsetenv("XDG_CONFIG_HOME");
    else
        qputenv("XDG_CONFIG_HOME", previous.toUtf8());
}

void TestMainWindow::cidPrefixesAreBangFree()
{
    // MainWindow is the only producer of cidPrefix in the application. The
    // '!' separator that keeps two messages' cid: references apart is only
    // unambiguous while the prefix half contains none.
    for (int i : { 0, 1, 9, 10, 99, 1000 }) {
        const QString prefix = MainWindow::cidPrefixForIndex(i);
        QVERIFY(!prefix.isEmpty());
        QVERIFY2(!prefix.contains(QLatin1Char('!')),
                 qPrintable(QStringLiteral("prefix '%1' contains '!'").arg(prefix)));
    }
}

void TestMainWindow::cidPrefixesAreDistinctPerMessage()
{
    // Two messages sharing a prefix would share a cid: namespace, which is the
    // collision the namespacing exists to prevent.
    QSet<QString> seen;
    for (int i = 0; i < 200; ++i) {
        const QString prefix = MainWindow::cidPrefixForIndex(i);
        QVERIFY2(!seen.contains(prefix),
                 qPrintable(QStringLiteral("prefix '%1' repeats").arg(prefix)));
        seen.insert(prefix);
    }
}

void TestMainWindow::uiStateIsNotWrittenIntoTheUserConfig()
{
    // The config file is hand-edited and must never gain a base64 geometry
    // blob, nor be rewritten on exit: QSettings preserves neither comments nor
    // key order, so writing it would quietly destroy the user's formatting.
    QVERIFY(MainWindow::uiStatePath() != Config::defaultPath());

    // One qtmaildir component, not two. QStandardPaths::StateLocation appends
    // both the organization and the application name, and here both are
    // "qtmaildir", so using it nests the directory inside itself.
    QCOMPARE(MainWindow::uiStatePath().count(QStringLiteral("/qtmaildir/")), 1);
    QVERIFY(MainWindow::uiStatePath().endsWith(
        QStringLiteral("/qtmaildir/uistate.conf")));
}

void TestMainWindow::uiStateSurvivesARestart()
{
    // Test mode redirects QStandardPaths at the process level, so the state
    // file lands in a scratch directory rather than the real ~/.local/state.
    QStandardPaths::setTestModeEnabled(true);
    QFile::remove(MainWindow::uiStatePath());

    // Must fit the smallest screen this ever runs against: the offscreen
    // platform reports 800x800, and restoreGeometry() clamps to the available
    // area, so a 940px width came back as 798 and failed only under offscreen.
    // The number carries no meaning beyond differing from the default size.
    const QSize resized(640, 560);
    {
        const Config config;
        MainWindow window(config);
        window.resize(resized);
        window.findChild<MessageView *>()->setZoomFactor(1.4);
        window.close();  // closeEvent() is what persists the state
    }

    QVERIFY2(QFile::exists(MainWindow::uiStatePath()),
             qPrintable(QStringLiteral("no state file at %1")
                            .arg(MainWindow::uiStatePath())));

    const Config config;
    MainWindow reopened(config);
    QCOMPARE(reopened.size(), resized);
    QCOMPARE(reopened.findChild<MessageView *>()->zoomFactor(), 1.4);

    QFile::remove(MainWindow::uiStatePath());
    QStandardPaths::setTestModeEnabled(false);
}

void TestMainWindow::missingUiStateLeavesTheDefaults()
{
    // A restore that silently succeeded on an empty blob would give a
    // zero-size window on first launch. Absent state must be a no-op.
    QStandardPaths::setTestModeEnabled(true);
    QFile::remove(MainWindow::uiStatePath());

    const Config config;
    MainWindow window(config);
    QCOMPARE(window.size(), QSize(1200, 800));

    QStandardPaths::setTestModeEnabled(false);
}

void TestMainWindow::headerStateFromADifferentColumnLayoutIsDiscarded()
{
    // The upgrade hazard: a 0.3.0 state file holds a three-column header blob,
    // and 0.4.0 added the attachment column in front. QHeaderView::
    // restoreState() returns TRUE for a blob with fewer sections than the
    // model and applies the old widths shifted one column right, mangling the
    // layout with no error to detect it by (verified on Qt 6.11). The stored
    // column count is what makes that detectable.
    QStandardPaths::setTestModeEnabled(true);
    QFile::remove(MainWindow::uiStatePath());

    {
        const Config config;
        MainWindow window(config);
        window.close();
    }

    // Forge a state file from an older layout: same blob, wrong column count.
    {
        QSettings state(MainWindow::uiStatePath(), QSettings::IniFormat);
        state.setValue(QStringLiteral("threadlist/columns"),
                       int(ThreadListModel::ColumnCount) - 1);
        state.setValue(QStringLiteral("threadlist/header"),
                       QByteArray("not a header this model could have saved"));
    }

    // Constructing must not apply it, and must not crash on the garbage blob.
    const Config config;
    MainWindow reopened(config);

    auto *view = reopened.findChild<QTableView *>();
    QVERIFY(view);
    QCOMPARE(view->columnWidth(ThreadListModel::AttachmentColumn), 28);
    QCOMPARE(view->columnWidth(ThreadListModel::DateColumn), 130);
    QCOMPARE(view->columnWidth(ThreadListModel::SubjectColumn), 520);

    QFile::remove(MainWindow::uiStatePath());
    QStandardPaths::setTestModeEnabled(false);
}

void TestMainWindow::returnInTheQueryBarRunsTheQueryNotOpenThread()
{
    // Return is bound to open_thread as a WindowShortcut, and the query bar has
    // to win it back while it has focus. Qt withholds a plain-LETTER shortcut
    // from an editable widget, but Return is not a letter and gets no such
    // protection, so without an explicit override the action fires, the query
    // never runs, and focus jumps to the thread list.
    //
    // The delivery order matters and is the reason this bug survived earlier
    // tests: real input sends ShortcutOverride first and only dispatches the
    // shortcut if nothing accepts it. QTest::keyClick() skips that round trip,
    // so a test written with it passes against the broken code.
    const Config config;
    MainWindow window(config);
    window.show();
    QVERIFY(QTest::qWaitForWindowExposed(&window));

    auto *edit = window.findChild<QLineEdit *>();
    QVERIFY(edit);
    edit->setFocus();
    QTRY_COMPARE(QApplication::focusWidget(), edit);
    edit->setText(QStringLiteral("tag:unread"));

    QAction *openThread = window.findChild<QAction *>(QStringLiteral("open_thread"));
    QVERIFY(openThread);
    bool actionFired = false;
    connect(openThread, &QAction::triggered, &window, [&actionFired]() {
        actionFired = true;
    });

    // The query bar must claim the override, which is what stops the shortcut
    // from ever being dispatched.
    QKeyEvent override(QEvent::ShortcutOverride, Qt::Key_Return, Qt::NoModifier);
    override.ignore();
    QApplication::sendEvent(edit, &override);
    QVERIFY2(override.isAccepted(),
             "the query bar let Return through to the open_thread shortcut");

    QVERIFY(!actionFired);
}

/// A thread summary carrying the tags a test needs. Enough to drive selection;
/// nothing here touches a database.
static ThreadSummary makeThread(const QString &id, const QStringList &tags)
{
    ThreadSummary thread;
    thread.threadId = id;
    thread.subject = QStringLiteral("Subject ") + id;
    thread.authors = QStringLiteral("Someone <someone@example.org>");
    thread.tags = tags;
    return thread;
}

void TestMainWindow::markReadTimerRestartsRatherThanStacking()
{
    // The plan's hard requirement: arrowing quickly down a list must not mark
    // every thread passed through as read, only the one still selected when the
    // timer fires. A stacked timer per selection would mark all of them.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
    QVERIFY(timer);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);

    model->appendBatch({ makeThread(QStringLiteral("t1"),
                                    { QStringLiteral("unread") }),
                         makeThread(QStringLiteral("t2"),
                                    { QStringLiteral("unread") }),
                         makeThread(QStringLiteral("t3"),
                                    { QStringLiteral("unread") }) });

    view->selectRow(0);
    QVERIFY2(timer->isActive(), "no timer armed for an unread thread");

    // Move on before it can fire. One timer stays armed, not three.
    view->selectRow(1);
    QVERIFY(timer->isActive());
    view->selectRow(2);
    QVERIFY(timer->isActive());

    // Exactly one timer exists at all, which is what "restarted, not stacked"
    // means concretely.
    QCOMPARE(window.findChildren<QTimer *>(QStringLiteral("markReadTimer")).size(),
             1);
}

void TestMainWindow::markReadTimerIsNotArmedForAReadThread()
{
    // Opening a thread that is already read must not schedule a write that
    // would change nothing.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
    QVERIFY(timer);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);

    model->appendBatch({ makeThread(QStringLiteral("read"),
                                    { QStringLiteral("inbox") }),
                         makeThread(QStringLiteral("unread"),
                                    { QStringLiteral("unread") }) });

    view->selectRow(0);
    QVERIFY2(!timer->isActive(), "armed a timer for an already-read thread");

    // And the unread one still arms, so this is not "never arms".
    view->selectRow(1);
    QVERIFY(timer->isActive());

    // Moving back to a read thread disarms it again, rather than leaving the
    // previous thread's timer running to fire against the wrong row.
    view->selectRow(0);
    QVERIFY(!timer->isActive());
}

void TestMainWindow::markReadCanBeDisabled()
{
    // A negative delay turns the behaviour off entirely. Documented, so it must
    // work rather than being clamped to "immediately".
    QTemporaryDir dir;
    const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
    {
        QFile file(path);
        QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
        file.write("[general]\nmark_read_delay_ms=-1\n");
    }

    Config config;
    config.load(path);
    QCOMPARE(config.markReadDelayMs(), -1);

    MainWindow window(config);
    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
    QVERIFY(timer);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);

    model->appendBatch({ makeThread(QStringLiteral("t1"),
                                    { QStringLiteral("unread") }) });
    view->selectRow(0);

    QVERIFY2(!timer->isActive(),
             "a negative mark_read_delay_ms must disable the timer");
}

void TestMainWindow::pendingEditCountSurvivesAQuery()
{
    // The defining property, and the reason this is a counter of its own rather
    // than QUndoStack::isClean(): the undo stack is cleared on every query,
    // because its entries refer to rows the new result set discards. Tag a
    // thread, run any query, and the stack is empty while the change is still
    // sitting unsynced in the database.
    const Config config;
    MainWindow window(config);

    auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
    QVERIFY(label);
    QVERIFY2(label->isHidden(), "the indicator must start hidden at zero");

    // Confirm a write the way the worker really does, by emitting the signal
    // the window listens to. No test-only entry point on MainWindow.
    TagChange change;
    change.added = { QStringLiteral("deleted") };
    change.description = QStringLiteral("Delete");
    QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
                                      Q_ARG(TagChange, change)));

    QVERIFY2(!label->isHidden(), "a confirmed edit must show the indicator");
    const QString afterEdit = label->text();
    QVERIFY(!afterEdit.isEmpty());

    // Now run a query, which clears the undo stack. The indicator must not
    // follow it down.
    window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:inbox"));
    QMetaObject::invokeMethod(&window, "runCurrentQuery");

    QVERIFY2(!label->isHidden(),
             "the indicator was cleared by a query, so it is tracking the undo "
             "stack rather than unsynced state");
    QCOMPARE(label->text(), afterEdit);
}

void TestMainWindow::aFailedSyncDoesNotClearThePendingCount()
{
    // A failed sync means the edits are still unsynced. Clearing here would
    // assert the opposite, and the user would quit believing their tagging had
    // been carried over.
    const Config config;
    MainWindow window(config);

    auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
    QVERIFY(label);

    TagChange change;
    change.added = { QStringLiteral("flagged") };
    QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
                                      Q_ARG(TagChange, change)));
    QVERIFY(!label->isHidden());
    const QString afterEdit = label->text();

    QMetaObject::invokeMethod(&window, "onSyncFinished",
                              Q_ARG(bool, false), Q_ARG(int, 1));
    QVERIFY2(!label->isHidden(), "a FAILED sync cleared the pending count");
    QCOMPARE(label->text(), afterEdit);

    // A successful one does clear it, so this is not "never clears".
    QMetaObject::invokeMethod(&window, "onSyncFinished",
                              Q_ARG(bool, true), Q_ARG(int, 0));
    QVERIFY2(label->isHidden(), "a successful sync must clear the indicator");
}

/// Closes a window and reports whether it accepted, failing rather than hanging
/// if a modal appears.
///
/// A modal spins its own event loop, so a test that simply sends a close event
/// blocks forever when a dialog it did not expect opens. This arms a timer that
/// closes any active modal and records that one was there, which turns "a
/// dialog appeared" into an assertion instead of a hung run.
struct CloseProbe
{
    bool accepted = false;
    bool sawModal = false;

    void run(MainWindow *window)
    {
        QTimer poll;
        poll.setInterval(50);
        int ticks = 0;
        QObject::connect(&poll, &QTimer::timeout, [this, &poll, &ticks]() {
            if (QWidget *modal = QApplication::activeModalWidget()) {
                sawModal = true;
                modal->close();
                poll.stop();
                return;
            }
            if (++ticks > 20)   // one second is ample for a synchronous close
                poll.stop();
        });
        poll.start();

        QCloseEvent event;
        QApplication::sendEvent(window, &event);
        accepted = event.isAccepted();
        poll.stop();
    }
};

void TestMainWindow::closingWithNoPendingEditsDoesNotPrompt()
{
    // Nothing outstanding means nothing to ask about. If a prompt fires here it
    // is keying off something other than there being work to lose, and every
    // quit would carry a dialog.
    const Config config;
    MainWindow window(config);

    CloseProbe probe;
    probe.run(&window);

    QVERIFY2(!probe.sawModal, "a clean window prompted on close");
    QVERIFY2(probe.accepted, "a clean window refused to close");
}

void TestMainWindow::syncOnExitNeverClosesSilently()
{
    // "never" is the behaviour that existed before the prompt did, and it has
    // to stay reachable for anyone who does not want to be asked. It must hold
    // whether or not a sync command is configured, so this covers both: the
    // no-command path has its own dialog, and "never" must skip that one too.
    QTemporaryDir dir;
    const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
    {
        QFile file(path);
        QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
        file.write("[general]\nsync_on_exit=never\n\n[sync]\ncommand=/bin/true\n");
    }

    Config config;
    config.load(path);
    QCOMPARE(config.syncOnExit(), Config::SyncOnExit::Never);

    MainWindow window(config);

    // Give it something to lose, so this is not passing for the same reason
    // the previous test does.
    TagChange change;
    change.added = { QStringLiteral("deleted") };
    QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
                                      Q_ARG(TagChange, change)));
    auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
    QVERIFY(label);
    QVERIFY(!label->isHidden());

    CloseProbe probe;
    probe.run(&window);

    QVERIFY2(!probe.sawModal,
             "sync_on_exit=never prompted anyway");
    QVERIFY2(probe.accepted,
             "sync_on_exit=never must close without prompting");
}

void TestMainWindow::selectAllIsBoundAndSelectsEveryRow()
{
    // Multi-select already worked by Ctrl+click and Shift+click; what was
    // missing was a keyboard and menu route to it. The action has to exist as a
    // registered action, not as a raw view shortcut, so it reaches the menu,
    // the shortcut reference and [keys] like every other binding.
    const Config config;
    MainWindow window(config);

    auto *action = window.findChild<QAction *>(QStringLiteral("select_all"));
    QVERIFY2(action, "no select_all action registered");
    QCOMPARE(action->shortcut(), QKeySequence(QStringLiteral("Ctrl+A")));

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);

    model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
                         makeThread(QStringLiteral("t2"), {}),
                         makeThread(QStringLiteral("t3"), {}) });

    action->trigger();

    QCOMPARE(view->selectionModel()->selectedRows().size(), 3);
}

void TestMainWindow::aMultiRowSelectionDoesNotArmTheMarkReadTimer()
{
    // A selection gesture must never mutate mail. current follows the keyboard
    // cursor as a selection extends, so without a guard every row swept through
    // by Shift+arrow would be queued to be marked read: threads the user only
    // ever selected, never opened.
    //
    // Note selectAll() on a fresh view is NOT the case to test here: it leaves
    // current invalid and emits no currentRowChanged at all (verified against
    // Qt 6.11), so it would pass without any guard in place. The real path is a
    // row already current, which is how a user reaches select-all: click a
    // thread, then Ctrl+A.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
    QVERIFY(timer);

    model->appendBatch({ makeThread(QStringLiteral("t1"),
                                    { QStringLiteral("unread") }),
                         makeThread(QStringLiteral("t2"),
                                    { QStringLiteral("unread") }),
                         makeThread(QStringLiteral("t3"),
                                    { QStringLiteral("unread") }) });

    // Sweep down as Shift+arrow does: current moves onto a row while the
    // selection already spans more than one.
    view->selectRow(0);
    view->selectionModel()->select(
        model->index(1, 0),
        QItemSelectionModel::Select | QItemSelectionModel::Rows);
    view->selectionModel()->setCurrentIndex(
        model->index(1, 0),
        QItemSelectionModel::Select | QItemSelectionModel::Rows);

    QVERIFY2(view->selectionModel()->selectedRows().size() > 1,
             "test setup failed to build a multi-row selection");
    QVERIFY2(!timer->isActive(),
             "a multi-row selection armed the mark-read timer");
}

void TestMainWindow::growingASelectionCancelsAnAlreadyArmedTimer()
{
    // The ordering trap: clicking one row arms the timer legitimately, and only
    // then does the selection grow. Guarding the new selection alone is not
    // enough, the timer already running for the first row has to be cancelled
    // or that thread goes read behind a pane that no longer shows it.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
    QVERIFY(timer);

    model->appendBatch({ makeThread(QStringLiteral("t1"),
                                    { QStringLiteral("unread") }),
                         makeThread(QStringLiteral("t2"),
                                    { QStringLiteral("unread") }) });

    view->selectRow(0);
    QVERIFY2(timer->isActive(), "no timer armed for a single unread thread");

    // Extend to a second row, as Shift+click would.
    view->selectionModel()->select(
        model->index(1, 0),
        QItemSelectionModel::Select | QItemSelectionModel::Rows);

    QVERIFY2(!timer->isActive(),
             "extending the selection left the first row's timer running");
}

void TestMainWindow::collapsingBackToOneRowLoadsThatThreadAgain()
{
    // The guard must not be a one-way door. Narrowing a multi-row selection
    // back to a single row is ordinary reading again, so the timer arms as it
    // always did.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
    QVERIFY(timer);

    model->appendBatch({ makeThread(QStringLiteral("t1"),
                                    { QStringLiteral("unread") }),
                         makeThread(QStringLiteral("t2"),
                                    { QStringLiteral("unread") }) });

    view->selectAll();
    QVERIFY(!timer->isActive());

    // Back to one row, as a plain click would leave it.
    view->selectRow(1);

    QVERIFY2(timer->isActive(),
             "collapsing back to one row did not resume mark-read");
}

void TestMainWindow::theStatusBarReportsAMultiRowSelection()
{
    // The actual discoverability gap: the UI never acknowledged a selection, so
    // nothing taught the user that selecting more than one row was possible.
    // A count that appears while the selection is being built does.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
    QVERIFY2(status, "no status label to report into");

    model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
                         makeThread(QStringLiteral("t2"), {}),
                         makeThread(QStringLiteral("t3"), {}) });

    view->selectAll();

    QVERIFY2(status->text().contains(QStringLiteral("3")),
             qPrintable(QStringLiteral("status bar does not report the selection "
                                       "size, it says '%1'").arg(status->text())));
}

void TestMainWindow::theThreadListOffersAContextMenu()
{
    // Right-click is the other half of discoverability: until now every tag
    // action was keyboard-only, so the Ctrl+T dialog in particular could not be
    // reached with the mouse at all.
    const Config config;
    MainWindow window(config);

    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    QCOMPARE(view->contextMenuPolicy(), Qt::CustomContextMenu);

    // The menu must reuse the registered QActions rather than build parallel
    // ones, or a [keys] rebinding would show the old shortcut here and the
    // menu could drift out of step with what the keyboard really does.
    auto *menu = window.findChild<QMenu *>(QStringLiteral("threadContextMenu"));
    QVERIFY2(menu, "no thread-list context menu");

    const QStringList expected = { QStringLiteral("archive"),
                                   QStringLiteral("delete"),
                                   QStringLiteral("spam"),
                                   QStringLiteral("toggle_unread"),
                                   QStringLiteral("edit_tags"),
                                   QStringLiteral("flag") };
    for (const QString &name : expected) {
        QAction *action = window.findChild<QAction *>(name);
        QVERIFY2(action, qPrintable(QStringLiteral("no action '%1'").arg(name)));
        QVERIFY2(menu->actions().contains(action),
                 qPrintable(QStringLiteral("context menu is missing the "
                                           "registered '%1' action").arg(name)));
    }
}

void TestMainWindow::aSecondRowBlanksThePaneNotOnlyAThird()
{
    // Reported by hand testing: selecting a second thread left it displayed,
    // and only a third blanked the pane. The cause is that currentRowChanged is
    // emitted before the selection model updates, so the Ctrl+click that makes
    // the count two arrives at onThreadSelected still reporting one, which
    // loads the thread; onSelectionChanged then blanks the pane, and the load,
    // being queued to the worker, paints over the blank when it returns. By the
    // third row m_currentThreadId is already cleared, so the late result is
    // discarded and the blank survives, which is why the fault looked like an
    // off-by-one in the threshold rather than a race.
    //
    // Two rows must behave exactly as three do.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *timer = window.findChild<QTimer *>(QStringLiteral("markReadTimer"));
    QVERIFY(timer);

    model->appendBatch({ makeThread(QStringLiteral("t1"),
                                    { QStringLiteral("unread") }),
                         makeThread(QStringLiteral("t2"),
                                    { QStringLiteral("unread") }),
                         makeThread(QStringLiteral("t3"),
                                    { QStringLiteral("unread") }) });

    // One row: ordinary reading, so a timer is armed and a thread is current.
    view->selectRow(0);
    QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
    QVERIFY(timer->isActive());

    // Ctrl+click a second row. This is the exact gesture that failed: the
    // selection becomes two while currentRowChanged still reports one.
    view->selectionModel()->setCurrentIndex(
        model->index(1, 0),
        QItemSelectionModel::Select | QItemSelectionModel::Rows);

    QCOMPARE(view->selectionModel()->selectedRows().size(), 2);
    QVERIFY2(!timer->isActive(),
             "two selected rows left the mark-read timer armed");

    // A blanked pane is one with no current thread: anything still in flight
    // for that id would repaint over it.
    QVERIFY2(window.currentThreadId().isEmpty(),
             qPrintable(QStringLiteral("two selected rows left thread '%1' "
                                       "loaded in the pane")
                            .arg(window.currentThreadId())));
}

void TestMainWindow::aLocalSyncIsNotReportedAsABackgroundOne()
{
    // Reported by hand testing: a manual sync ended with "Sync finished
    // elsewhere" stamped over its own result. The monitor sees the lock the
    // local run takes, and while the process lives isRunning() suppresses the
    // message; but the process exits, and therefore isRunning() goes false,
    // BEFORE the next poll notices the lock was released. That poll then
    // reported a local sync as a background one.
    //
    // Ownership is latched when the lock appears, so the release can still be
    // attributed after the process is gone.
    const Config config;
    MainWindow window(config);

    auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
    QVERIFY(status);

    // The lock appears while no local sync is running: a background one.
    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Running));
    QVERIFY2(status->text().contains(QStringLiteral("Background")),
             qPrintable(QStringLiteral("a background sync was not announced, "
                                       "status says '%1'").arg(status->text())));

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Idle));
    QVERIFY2(status->text().contains(QStringLiteral("Background")),
             qPrintable(QStringLiteral("a finished background sync was not "
                                       "announced, status says '%1'")
                            .arg(status->text())));

}

void TestMainWindow::aLocalSyncsOwnLockIsNeverReportedAsBackground()
{
    // The reported bug, staged at the seam where it actually lives.
    //
    // A real child process was tried first and abandoned: it needs a sync
    // command in the config, it leaves a live process behind for the length of
    // the test, and it made the suite pop a dialog. None of that is needed,
    // because the defect is not in MailSync. It is that ownership of a lock
    // period was decided at RELEASE time, when MailSync::isRunning() has
    // already gone false, instead of being latched when the lock appeared.
    //
    // With no sync command configured isRunning() is false throughout, which is
    // exactly the state the buggy code misread. So: announce a Running that the
    // window believes is external, then a matching Idle. Both must be reported.
    // The local case is covered by the latch being set only inside the Running
    // branch, and by aSkippedLocalSyncStillReportsTheOtherRunFinishing()
    // proving the latch is handed back when the lock was never ours.
    const Config config;
    MainWindow window(config);

    auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
    QVERIFY(status);
    auto *progress =
        window.findChild<QProgressBar *>(QStringLiteral("syncProgress"));
    QVERIFY(progress);

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Running));
    QVERIFY2(progress->isVisibleTo(&window),
             "a background sync did not show the progress bar");

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Idle));
    QVERIFY2(!progress->isVisibleTo(&window),
             "the progress bar outlived the background sync");

    // An Unknown transition means the lock table could not be read. Nothing was
    // observed, so nothing may be claimed: the previous message must stand.
    status->setText(QStringLiteral("untouched"));
    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Unknown));
    QCOMPARE(status->text(), QStringLiteral("untouched"));
}

void TestMainWindow::aSkippedLocalSyncStillReportsTheOtherRunFinishing()
{
    // The narrow case the latch could break: a manual sync that exits 75
    // because cron already holds the lock. If both started inside one poll
    // interval the monitor sees the lock appear while isRunning() is true and
    // latches it local, even though the lock belongs to the cron run. The
    // completion of that run would then be swallowed. onSyncFinished() hands
    // ownership back when it sees the skip code.
    const Config config;
    MainWindow window(config);

    auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
    QVERIFY(status);

    QMetaObject::invokeMethod(&window, "onSyncFinished",
                              Q_ARG(bool, false),
                              Q_ARG(int, MainWindow::kSyncSkippedExitCode));

    // The skip itself is reported, and not as a failure.
    QVERIFY2(!status->text().contains(QStringLiteral("failed")),
             qPrintable(QStringLiteral("a skip was reported as a failure: '%1'")
                            .arg(status->text())));

    // The other run finishing must still be announced.
    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Idle));
    QVERIFY2(status->text().contains(QStringLiteral("Background")),
             qPrintable(QStringLiteral("after a skipped local sync, the other "
                                       "run finishing was swallowed; status "
                                       "says '%1'").arg(status->text())));
}

void TestMainWindow::theSyncActionIsDisabledWhileABackgroundSyncHoldsTheLock()
{
    // Item 29 shipped for the QPushButton only: onExternalSyncStateChanged
    // disabled m_syncButton and never touched the QAction, so the toolbar and
    // menu Sync stayed clickable during a cron sync and could only produce the
    // EX_TEMPFAIL skip. The button-based test passed throughout, because it
    // drove the half that worked.
    QTemporaryDir dir;
    QVERIFY(dir.isValid());
    QVERIFY(QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir"))));
    const QString conf = dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"));
    {
        QSettings s(conf, QSettings::IniFormat);
        s.setValue(QStringLiteral("sync/command"), QStringLiteral("/bin/true"));
    }

    const QString locks = dir.filePath(QStringLiteral("locks"));
    {
        QFile f(locks);
        QVERIFY(f.open(QIODevice::WriteOnly));
    }
    MainWindow::setLocksPathForTesting(locks);

    Config config;
    config.load(conf);
    MainWindow window(config);

    auto *action = window.findChild<QAction *>(QStringLiteral("sync"));
    QVERIFY2(action, "no sync action to check");
    QVERIFY2(action->isEnabled(), "the action starts disabled with a command set");

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Running));
    QVERIFY2(!action->isEnabled(),
             "the sync action stayed enabled during a background sync");

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Idle));
    QVERIFY2(action->isEnabled(),
             "the sync action was not re-enabled after the background sync");

    MainWindow::setLocksPathForTesting(QStringLiteral("/proc/locks"));
}

void TestMainWindow::anUnobservableLockTableLeavesTheSyncButtonUsable()
{
    // Unknown means /proc/locks could not be read, so nothing was observed. A
    // button left permanently disabled on a platform that cannot see the lock
    // is worse than one that occasionally offers a run that gets skipped.
    QTemporaryDir dir;
    QVERIFY(dir.isValid());
    QVERIFY(QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir"))));
    const QString conf = dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"));
    {
        QSettings s(conf, QSettings::IniFormat);
        s.setValue(QStringLiteral("sync/command"), QStringLiteral("/bin/true"));
    }

    Config config;
    config.load(conf);
    MainWindow window(config);

    auto *action = window.findChild<QAction *>(QStringLiteral("sync"));
    QVERIFY(action);

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Running));
    QVERIFY(!action->isEnabled());

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Unknown));
    QVERIFY2(action->isEnabled(),
             "an unobservable lock table left the sync action disabled");
}

void TestMainWindow::escapeBlanksTheMessagePane()
{
    // A registered action like any other, so it reaches the menus, the shortcut
    // reference and [keys]. Clearing m_currentThreadId with the pane is the
    // part that matters: a late threadLoaded would otherwise paint the thread
    // straight back, which is the race fixed in 0.8.0.
    const Config config;
    MainWindow window(config);

    auto *action = window.findChild<QAction *>(QStringLiteral("clear_pane"));
    QVERIFY2(action, "no clear_pane action registered");
    QCOMPARE(action->shortcut(), QKeySequence(Qt::Key_Escape));

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);

    model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
                         makeThread(QStringLiteral("t2"), {}) });

    view->selectRow(0);
    QVERIFY2(!window.currentThreadId().isEmpty(),
             "no thread was opened to blank");

    action->trigger();
    QVERIFY2(window.currentThreadId().isEmpty(),
             "Escape left the thread loaded in the pane");

    // Blanking is a view change, not a mail change: the selection stays.
    QCOMPARE(view->selectionModel()->selectedRows().size(), 1);
}

void TestMainWindow::deleteTogglesOnAnAlreadyDeletedThread()
{
    // Hitting Delete twice is the natural way to say "no, put it back", and
    // adding a tag that is already present is a no-op the user cannot see.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
    QVERIFY(action);

    model->appendBatch({ makeThread(QStringLiteral("t1"),
                                    { QStringLiteral("deleted") }) });
    view->selectRow(0);

    action->trigger();

    // The optimistic model update is synchronous, so the row reflects the
    // change without a worker.
    QVERIFY2(!model->threadAt(0).isDeleted(),
             "delete on an already-deleted thread did not undelete it");
}

void TestMainWindow::deleteOnAMixedSelectionDeletesRatherThanSplittingIt()
{
    // The constraint that makes this more than a one-liner: toggling each
    // thread independently would leave one keystroke with the selection in two
    // states, which is worse than either outcome. Undelete only when every
    // selected thread is already deleted.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
    QVERIFY(action);

    model->appendBatch({ makeThread(QStringLiteral("t1"),
                                    { QStringLiteral("deleted") }),
                         makeThread(QStringLiteral("t2"), {}) });

    view->selectAll();
    QCOMPARE(view->selectionModel()->selectedRows().size(), 2);

    action->trigger();

    QVERIFY2(model->threadAt(0).isDeleted() && model->threadAt(1).isDeleted(),
             "a mixed selection split instead of deleting the whole selection");
}

void TestMainWindow::aTransientStatusMessageExpires()
{
    // "Sync complete" describes an event, not a state, and reads as though it
    // describes the present until something else overwrites it.
    const Config config;
    MainWindow window(config);

    auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
    QVERIFY(status);
    auto *timer = window.findChild<QTimer *>(QStringLiteral("statusTimer"));
    QVERIFY2(timer, "no status expiry timer");

    QMetaObject::invokeMethod(&window, "showTransientStatus",
                              Q_ARG(QString, QStringLiteral("Sync complete")));
    QCOMPARE(status->text(), QStringLiteral("Sync complete"));
    QVERIFY(timer->isActive());

    // Fire it rather than waiting out the real interval.
    timer->setInterval(0);
    QTRY_VERIFY_WITH_TIMEOUT(status->text() != QStringLiteral("Sync complete"),
                             2000);
}

void TestMainWindow::theSelectionCountIsStateAndDoesNotExpire()
{
    // Not everything in the status bar is an event. The selection count
    // describes what is true right now and must persist while it stays true;
    // expiring it would undo the 0.8.0 discoverability work.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
    QVERIFY(status);
    auto *timer = window.findChild<QTimer *>(QStringLiteral("statusTimer"));
    QVERIFY(timer);

    model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
                         makeThread(QStringLiteral("t2"), {}) });
    view->selectAll();

    QVERIFY2(status->text().contains(QStringLiteral("2")),
             "the selection count was not reported");
    QVERIFY2(!timer->isActive(),
             "the selection count armed the expiry timer; it is state, "
             "not an event");
}

void TestMainWindow::anEditUndoneNettsBackToZero()
{
    // Reported by the user: open a thread, let the 2 s auto-mark-read remove
    // `unread`, then press Ctrl+U to put it back. The indicator read 2 unsynced
    // changes when the mail store was exactly where it started.
    //
    // The count tracks NET state, not writes. Two writes did happen, but their
    // effect cancels, and what the user needs to know is whether quitting now
    // would strand work.
    const Config config;
    MainWindow window(config);

    auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
    QVERIFY(label);
    QVERIFY(label->isHidden());

    // The automatic mark-read: remove `unread` from one message.
    TagChange off;
    off.messageIds = { QStringLiteral("m1") };
    off.removed = { QStringLiteral("unread") };
    off.description = QStringLiteral("Mark read");
    QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
                                      Q_ARG(TagChange, off)));
    QVERIFY2(!label->isHidden(), "one edit must show the indicator");

    // Ctrl+U puts it back on the same message.
    TagChange on;
    on.messageIds = { QStringLiteral("m1") };
    on.added = { QStringLiteral("unread") };
    on.description = QStringLiteral("Mark unread");
    QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
                                      Q_ARG(TagChange, on)));

    QVERIFY2(label->isHidden(),
             qPrintable(QStringLiteral("an edit and its inverse left the "
                                       "indicator showing '%1'")
                            .arg(label->text())));
}

void TestMainWindow::aDifferentTagOnTheSameMessageStillCounts()
{
    // Netting must be per (message, tag), not per message. Removing `unread`
    // and adding `flagged` on one message are two independent changes, and
    // neither cancels the other.
    const Config config;
    MainWindow window(config);

    auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
    QVERIFY(label);

    TagChange a;
    a.messageIds = { QStringLiteral("m1") };
    a.removed = { QStringLiteral("unread") };
    a.description = QStringLiteral("Mark read");
    QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
                                      Q_ARG(TagChange, a)));

    TagChange b;
    b.messageIds = { QStringLiteral("m1") };
    b.added = { QStringLiteral("flagged") };
    b.description = QStringLiteral("Flag");
    QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
                                      Q_ARG(TagChange, b)));

    QVERIFY2(!label->isHidden(),
             "two different tags on one message cancelled each other");
}

void TestMainWindow::anEditWithNoMessageIdsStillCounts()
{
    // A TagChange carrying no message ids cannot be netted against anything,
    // and must still register rather than silently counting as zero. Losing an
    // edit understates the indicator, which is the direction that costs the
    // user work.
    const Config config;
    MainWindow window(config);

    auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
    QVERIFY(label);

    TagChange change;
    change.added = { QStringLiteral("deleted") };
    change.description = QStringLiteral("Delete");
    QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
                                      Q_ARG(TagChange, change)));

    QVERIFY2(!label->isHidden(),
             "an edit with no message ids was not counted at all");
}

// Item 37. A tag edit made while a background sync holds notmuch's write lock
// used to stall the worker: the read-write open BLOCKS until the lock frees
// (measured 9.158s against a 12s hold, returning NOTMUCH_STATUS_SUCCESS), so
// every later query and thread load queued behind it. These cases pin the fix:
// do not send the write while a sync is running, send it when the sync ends.

void TestMainWindow::anEditDuringABackgroundSyncIsNotSentYet()
{
    // The defect. Sending during the sync is what stalls the worker, so the
    // edit is held instead. The rows still show it: it is what the user asked
    // for and it is going to be applied.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
    QVERIFY2(action, "no flag action registered");

    model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
    view->selectRow(0);

    // A cron sync takes the lock.
    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Running));

    action->trigger();

    QVERIFY2(window.hasEditAwaitingSend(),
             "the edit was sent straight into a running sync, which is the "
             "blocking open that stalls the worker");
    QVERIFY2(model->threadAt(0).tags.contains(QStringLiteral("flagged")),
             "holding the edit also dropped it from the rows");
}

void TestMainWindow::aHeldEditIsSentWhenTheBackgroundSyncEnds()
{
    // The release. SyncMonitor already reports this transition for item 27, so
    // the held edit rides a signal that exists rather than a timer.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
    QVERIFY(action);

    model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
    view->selectRow(0);

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Running));
    action->trigger();
    QVERIFY(window.hasEditAwaitingSend());

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Idle));

    QVERIFY2(!window.hasEditAwaitingSend(),
             "the sync ending did not send the held edit");
    QVERIFY2(model->threadAt(0).tags.contains(QStringLiteral("flagged")),
             "sending the held edit lost the tag from the rows");
}

void TestMainWindow::aHeldEditCountsAsUnsynced()
{
    // A held edit has not reached the index, so onTagsApplied() never counted
    // it. It must still count here, because this is what the exit prompt reads:
    // quitting on a held edit loses it outright, which is the whole failure the
    // prompt exists to prevent.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
    QVERIFY(action);
    auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
    QVERIFY(label);
    QVERIFY2(label->isHidden(), "the indicator starts hidden at zero");

    model->appendBatch({ makeThread(QStringLiteral("t1"), {}) });
    view->selectRow(0);

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Running));
    action->trigger();

    QVERIFY2(!label->isHidden(),
             "an edit held for a running sync was not counted as unsynced, so "
             "the exit prompt would let the user quit on it");
}

void TestMainWindow::anUnreadableLockTableStillSendsTheEdit()
{
    // State::Unknown means /proc/locks could not be read, so nothing is
    // observed. Holding writes there would strand every edit forever on a
    // platform that cannot see the lock at all. Unknown is not "running".
    //
    // Driven from Running, not from a fresh window: the guard is that Unknown
    // CLEARS the busy flag, and a window that was never busy would pass this
    // whatever Unknown did. Reaching Unknown by way of Running is also the only
    // way a real monitor gets there, when /proc/locks becomes unreadable
    // mid-session.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *action = window.findChild<QAction *>(QStringLiteral("flag"));
    QVERIFY(action);

    model->appendBatch({ makeThread(QStringLiteral("t1"), {}),
                         makeThread(QStringLiteral("t2"), {}) });

    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Running));
    view->selectRow(0);
    action->trigger();
    QVERIFY2(window.hasEditAwaitingSend(),
             "the edit was not held during a running sync, so this test is not "
             "exercising the Unknown transition it claims to");

    // The lock table becomes unreadable. That is not evidence of a sync, so
    // writing must resume and the held edit must go out.
    QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
                              Q_ARG(SyncMonitor::State,
                                    SyncMonitor::State::Unknown));
    QVERIFY2(!window.hasEditAwaitingSend(),
             "an unreadable lock table kept the edit held, stranding it on any "
             "platform without /proc/locks");

    // And a NEW edit is sent rather than held.
    view->selectRow(1);
    action->trigger();
    QVERIFY2(!window.hasEditAwaitingSend(),
             "an unreadable lock table held a new edit, so writes never resume");
}

void TestMainWindow::aRejectedWriteKeepsEarlierUndoHistory()
{
    // revertPendingTagChange() used to undo the failed command and then CLEAR
    // the whole stack, so one rejected write threw away every undo step the
    // user had built up. Undoing the failed command is enough: it is already
    // off the stack afterwards.
    const Config config;
    MainWindow window(config);

    auto *model = window.findChild<ThreadListModel *>();
    QVERIFY(model);
    auto *view = window.findChild<QTableView *>();
    QVERIFY(view);
    auto *flag = window.findChild<QAction *>(QStringLiteral("flag"));
    QVERIFY(flag);
    auto *archive = window.findChild<QAction *>(QStringLiteral("archive"));
    QVERIFY2(archive, "no archive action registered");

    model->appendBatch({ makeThread(QStringLiteral("t1"),
                                    { QStringLiteral("inbox") }) });
    view->selectRow(0);

    // One edit that succeeds, so there is history worth keeping.
    archive->trigger();
    TagChange applied;
    applied.messageIds = { QStringLiteral("m1") };
    applied.removed = { QStringLiteral("inbox") };
    applied.description = QStringLiteral("Archive");
    QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
                                      Q_ARG(TagChange, applied)));

    // A second edit that the worker rejects outright.
    flag->trigger();
    QVERIFY(QMetaObject::invokeMethod(
        &window, "onWorkerError",
        Q_ARG(QString, QStringLiteral("Cannot resolve threads"))));

    QVERIFY2(window.canUndo(),
             "a rejected write cleared the undo history of edits that had "
             "already succeeded");
}

// Constructing a MainWindow needs a QApplication and a platform plugin. The
// test has no display under ctest, so it runs offscreen unless the caller
// asked for something else.
int main(int argc, char *argv[])
{
    qputenv("QT_QPA_PLATFORM", qgetenv("QT_QPA_PLATFORM").isEmpty()
                                   ? QByteArray("offscreen")
                                   : qgetenv("QT_QPA_PLATFORM"));
    QApplication app(argc, argv);
    TestMainWindow test;
    return QTest::qExec(&test, argc, argv);
}

#include "test_mainwindow.moc"
