// Sane Break is a gentle break reminder that helps you avoid mindlessly skipping breaks
// Copyright (C) 2024-2026 Sane Break developers
// SPDX-License-Identifier: GPL-3.0-or-later

#include <gmock/gmock.h>
#include <qtestcase.h>

#include <QDateTime>
#include <QObject>
#include <QRegularExpression>
#include <QSignalSpy>
#include <QSqlQuery>
#include <QTest>
#include <QTimer>

#include "core/app.h"
#include "core/flags.h"
#include "core/idle-time.h"
#include "dummy.h"
#include "gmock/gmock.h"

using testing::Mock, testing::NiceMock, testing::_;

static int sumTrackedSeconds(BreakDatabase* db, const QDate& from, const QDate& to) {
  int total = 0;
  for (const auto& stats : db->queryDailyUsageStats(from, to)) {
    total += stats.trackedSeconds;
  }
  return total;
}

class TestApp : public QObject {
  Q_OBJECT
 private:
  QObject* depsParent;
  DummyAppDependencies deps;
 private slots:
  void init() {
    QTest::failOnWarning();
    depsParent = new QObject();
    deps = DummyApp::makeDeps(depsParent);
  }
  void cleanup() { delete depsParent; }
  // A corrupt config value (below the setting's valid minimum) must normalize to
  // its default instead of being used as-is. This prevents a division-by-zero
  // crash in breakDuration() when smallEvery is 0, and keeps other break-math
  // settings sane. Each invalid read emits an expected "Invalid value for setting"
  // warning; it's paired with the read that triggers it so the warning/assert
  // coupling stays local and reorder-safe. init()'s failOnWarning() still catches
  // any *unexpected* warning.
  void settings_normalize_invalid_config() {
    const QRegularExpression invalidMsg("Invalid value for setting");

    QTemporaryFile tempFile;
    QVERIFY(tempFile.open());
    QString path = tempFile.fileName();
    tempFile.close();
    {
      QSettings s(path, QSettings::IniFormat);
      s.setValue("break/small-every", 0);
      s.setValue("break/small-for", 5);  // valid
      s.setValue("break/big-after", 0);
      s.setValue("break/flash-for", 0);
      s.setValue("pause/reset-after", 0);
      s.setValue("postpone/max-minute-ratio", -20);
      s.setValue("postpone/shrink-next-session-ratio", -1);
      s.setValue("break/flash-speed", -1);
      s.setValue("break/max-force-break-exits", -1);
      s.setValue("break/auto-screen-lock", -1);
      s.setValue("break/confirm-after", 0);
      s.setValue("break/flash-tray-for", -5);
      s.setValue("pause/on-idle-for", 0);
      s.sync();
    }
    SanePreferences p(new QSettings(path, QSettings::IniFormat));

    // Read a setting and, if it is expected to warn, consume that warning here.
    // The QCOMPARE is the real check; the ignoreMessage merely declares "this
    // warning is expected" so failOnWarning() still guards anything unexpected.
    auto check = [&](Setting<int>* setting, int expected, bool warns) {
      if (warns) QTest::ignoreMessage(QtWarningMsg, invalidMsg);
      QCOMPARE(setting->get(), expected);
    };

    // positiveInt bound -> stored <= 0 normalizes to default
    check(p.smallEvery, 1200, true);      // 0
    check(p.bigAfter, 3, true);           // 0
    check(p.flashFor, 30, true);          // 0
    check(p.resetAfterPause, 120, true);  // 0
    // nonNegativeInt bound -> stored < 0 normalizes to default
    check(p.postponeMaxMinutePercent, 50, true);    // -20
    check(p.postponeShrinkNextPercent, 100, true);  // -1
    check(p.flashSpeed, 120, true);                 // -1
    check(p.maxForceBreakExits, 2, true);           // -1
    check(p.autoScreenLock, 0, true);               // -1
    // a valid stored value is kept as-is (no warning emitted)
    check(p.smallFor, 5, false);
    // more invalid reads across both bounds
    check(p.confirmAfter, 30, true);     // 0
    check(p.flashTrayFor, 30, true);     // -5
    check(p.pauseOnIdleFor, 180, true);  // 0
  }
  void app_initial_state() {
    NiceMock<DummyApp> app(deps);
    app.start();

    QCOMPARE(app.trayData.pauseReasons.toInt(), 0);
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get());
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);
  }
  void tick() {
    NiceMock<DummyApp> app(deps);
    app.start();

    QVERIFY(deps.countDownTimer->isActive());

    emit deps.countDownTimer->timeout();
    // Time has ellapsed
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get() - 1);
  }
  // Simple test if the break window shows after the countdown ends.
  void show_first_break() {
    NiceMock<DummyApp> app(deps);
    app.start();

    // Break window will show
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.advance(app.trayData.secondsToNextBreak);
    // Correctly advaces to next break
    QCOMPARE(app.trayData.secondsToNextBreak, 0);

    // Countdown is stopped
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, 0);

    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));

    // Simulate break window close
    app.advanceToBreakEnd();

    // The remaining time is correct
    int smallEvery = deps.preferences->smallEvery->get();
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery);

    // Countdown is stopped
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery - 1);
  }
  /* For consistency and easy testing, the timer for the break windows is actually
   * managed by the app. Here we test if advancing the app timer is currectly
   * controlling the break windows. We are not emitting idle events and we rely on the
   * force break mechanism.
   */
  void break_tick() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.advance(app.trayData.secondsToNextBreak);
    QCOMPARE(app.trayData.secondsToNextBreak, 0);

    QVERIFY(app.trayData.isBreaking);

    int expectedTimeForBreak =
        deps.preferences->smallFor->get() + deps.preferences->flashFor->get();
    app.advance(expectedTimeForBreak);
    QVERIFY(!app.trayData.isBreaking);

    // The remaining time is correct
    int smallEvery = deps.preferences->smallEvery->get();
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery);
  }
  // Advance a full cycle of breaks and see if small and big breaks are shown correctly
  void show_big_break() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int numberOfBreaks = deps.preferences->bigAfter->get();
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _))
        .Times(numberOfBreaks - 1);
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Big, _, _, _)).Times(1);
    for (int j = 0; j < numberOfBreaks; j++) {
      QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, numberOfBreaks - j - 1);
      app.advance(app.trayData.secondsToNextBreak);
      app.advanceToBreakEnd();
    }
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
  }
  /* If the user is idle after the break finishes, we should not start the countdown
   * until the user comes back. This is important because if we don't do so, we are kind
   * of telling the user to engage in work immediately after the break since the clock
   * is ticking.
   */
  void countdown_pause_if_idle_after_break() {
    NiceMock<DummyApp> app(deps);
    app.start();
    app.advance(app.trayData.secondsToNextBreak);
    deps.idleTimer->setIdle(true);
    app.advanceToBreakEnd();

    // The remaining time is correct
    int smallEvery = deps.preferences->smallEvery->get();
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery);

    // Countdown is paused if user is idle after break
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery);

    deps.idleTimer->setIdle(false);

    // Countdown resumed
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery - 1);
  }
  // See `countdown_pause_if_idle_after_break`
  void countdown_resume_if_not_idle_after_break() {
    NiceMock<DummyApp> app(deps);
    app.start();
    app.advance(app.trayData.secondsToNextBreak);

    deps.idleTimer->setIdle(true);
    deps.idleTimer->setIdle(false);

    app.advanceToBreakEnd();

    // Countdown resumed
    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak - 1);
  }
  // Default preference (treatInhibitorAsActivity == true) maps to InhibitorAware.
  void idle_mode_default_is_inhibitor_aware() {
    NiceMock<DummyApp> app(deps);
    QCOMPARE(deps.preferences->treatInhibitorAsActivity->get(), true);
    app.start();
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InhibitorAware);
  }
  // Setting the preference to false before start maps to InputOnly.
  void idle_mode_false_pref_is_input_only() {
    deps.preferences->treatInhibitorAsActivity->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InputOnly);
  }
  // Changing the preference at runtime re-applies the mode live.
  void idle_mode_live_change() {
    NiceMock<DummyApp> app(deps);
    app.start();
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InhibitorAware);
    deps.preferences->treatInhibitorAsActivity->set(false);
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InputOnly);
    deps.preferences->treatInhibitorAsActivity->set(true);
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InhibitorAware);
  }
  // During a break, idle must be measured with raw input (InputOnly) regardless of
  // the preference, so phase transitions and force-break escalation correctly detect
  // actual user presence instead of being masked by a background inhibitor.
  void idle_mode_during_break_is_input_only() {
    NiceMock<DummyApp> app(deps);
    app.start();
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InhibitorAware);
    app.advance(app.trayData.secondsToNextBreak);
    QVERIFY(app.trayData.isBreaking);
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InputOnly);
  }
  // After a break ends, the preferred mode is restored when returning to normal.
  void idle_mode_restores_after_break() {
    NiceMock<DummyApp> app(deps);
    app.start();
    app.advance(app.trayData.secondsToNextBreak);
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InputOnly);
    app.advanceToBreakEnd();
    QVERIFY(!app.trayData.isBreaking);
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InhibitorAware);
  }
  // State entries apply the idle threshold and mode in one setIdleDetection() call
  // (backends that re-arm their watcher only re-create the request once), with the
  // right values for each state.
  void idle_settings_applied_together_on_state_entries() {
    NiceMock<DummyApp> app(deps);
    app.start();
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InhibitorAware);
    QCOMPARE(deps.idleTimer->recordedMinIdleTime(),
             deps.preferences->pauseOnIdleFor->get() * 1000);

    app.advance(app.trayData.secondsToNextBreak);
    QVERIFY(app.trayData.isBreaking);
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InputOnly);
    QCOMPARE(deps.idleTimer->recordedMinIdleTime(), 2000);

    app.advanceToBreakEnd();
    QVERIFY(!app.trayData.isBreaking);
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InhibitorAware);
    QCOMPARE(deps.idleTimer->recordedMinIdleTime(),
             deps.preferences->pauseOnIdleFor->get() * 1000);
  }
  // Some backends (InhibitorProxyIdleTime over Wayland) emit idleStart
  // synchronously from setIdleDetection when a break enters while the user is idle
  // and an inhibitor had masked the pause. The phase does not exist yet during
  // enter(); the signal must be dropped and the initial phase picked from
  // idleTimer->isIdle() right after, instead of dereferencing a null phase.
  void idle_start_during_break_entry_is_handled_safely() {
    NiceMock<DummyApp> app(deps);
    app.start();
    deps.idleTimer->setEmitIdleStartOnDetection(true);
    app.advance(app.trayData.secondsToNextBreak);
    QVERIFY(app.trayData.isBreaking);
    // The synchronous signal set the idle state, so enter() chose the full-screen
    // phase from idleTimer->isIdle() and the break still completes normally.
    QVERIFY(deps.idleTimer->isIdle());
    app.advanceToBreakEnd();
    QVERIFY(!app.trayData.isBreaking);
  }
  // Force break also uses InputOnly — an inhibitor must not prevent force-break
  // escalation when the user is actually active.
  void idle_mode_during_force_break_is_input_only() {
    NiceMock<DummyApp> app(deps);
    app.start();
    app.advance(app.trayData.secondsToNextBreak);
    // User stays "active" (not idle) so the break escalates to force break.
    app.advanceToForceBreakStart();
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InputOnly);
    app.advanceToBreakEnd();
    QCOMPARE(deps.idleTimer->recordedMode(), IdleMode::InhibitorAware);
  }
  void long_post_break_idle_resets_cycle_data() {
    QTest::addColumn<bool>("autoCloseWindow");
    QTest::newRow("auto-close") << true;
    QTest::newRow("keep-window-open") << false;
  }
  void long_post_break_idle_resets_cycle() {
    QFETCH(bool, autoCloseWindow);
    deps.preferences->autoCloseWindowAfterSmallBreak->set(autoCloseWindow);
    NiceMock<DummyApp> app(deps);
    app.start();

    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);

    app.breakNow();
    deps.idleTimer->setIdle(true);
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 2);

    app.advance(deps.preferences->resetCycleAfterPause->get() + 1);
    deps.idleTimer->setIdle(false);

    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);
    QVERIFY(!app.trayData.pauseReasons);
  }
  void short_post_break_idle_does_not_reset_cycle_data() {
    QTest::addColumn<bool>("autoCloseWindow");
    QTest::newRow("auto-close") << true;
    QTest::newRow("keep-window-open") << false;
  }
  void short_post_break_idle_does_not_reset_cycle() {
    QFETCH(bool, autoCloseWindow);
    deps.preferences->autoCloseWindowAfterSmallBreak->set(autoCloseWindow);
    NiceMock<DummyApp> app(deps);
    app.start();
    int threshold = deps.preferences->bigFor->get() - deps.preferences->smallFor->get();

    app.breakNow();
    deps.idleTimer->setIdle(true);
    app.advanceToBreakEnd();
    app.advance(threshold > 0 ? threshold - 1 : 0);
    deps.idleTimer->setIdle(false);

    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 2);
  }
  void post_break_idle_cycle_reset_uses_big_minus_small() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int threshold = deps.preferences->bigFor->get() - deps.preferences->smallFor->get();
    QVERIFY(threshold < deps.preferences->resetCycleAfterPause->get());

    app.breakNow();
    deps.idleTimer->setIdle(true);
    app.advanceToBreakEnd();
    app.advance(threshold + 1);
    deps.idleTimer->setIdle(false);

    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);
  }
  void long_post_break_idle_undoes_postpone_shrink_data() {
    QTest::addColumn<bool>("autoCloseWindow");
    QTest::newRow("auto-close") << true;
    QTest::newRow("keep-window-open") << false;
  }
  void long_post_break_idle_undoes_postpone_shrink() {
    QFETCH(bool, autoCloseWindow);
    deps.preferences->autoCloseWindowAfterSmallBreak->set(autoCloseWindow);
    NiceMock<DummyApp> app(deps);
    app.start();

    int smallEvery = deps.preferences->smallEvery->get();
    app.postpone(100);
    app.advance(app.trayData.secondsToNextBreak);
    deps.idleTimer->setIdle(true);
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery - 100);

    app.advance(deps.preferences->resetCycleAfterPause->get() + 1);
    deps.idleTimer->setIdle(false);

    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery);
  }
  void short_post_break_idle_keeps_postpone_shrink_data() {
    QTest::addColumn<bool>("autoCloseWindow");
    QTest::newRow("auto-close") << true;
    QTest::newRow("keep-window-open") << false;
  }
  void short_post_break_idle_keeps_postpone_shrink() {
    QFETCH(bool, autoCloseWindow);
    deps.preferences->autoCloseWindowAfterSmallBreak->set(autoCloseWindow);
    NiceMock<DummyApp> app(deps);
    app.start();

    int smallEvery = deps.preferences->smallEvery->get();
    app.postpone(100);
    app.advance(app.trayData.secondsToNextBreak);
    deps.idleTimer->setIdle(true);
    app.advanceToBreakEnd();
    app.advance(deps.preferences->resetCycleAfterPause->get() - 1);
    deps.idleTimer->setIdle(false);

    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery - 100);
  }
  void finalize_pending_post_break_emits_once() {
    AppData data(nullptr, deps.preferences);
    data.makeNextBreakBig();
    data.schedule().setSecondsToNextBreak(123);
    data.postBreak().setPending({
        .completedBreakType = BreakType::Small,
        .wasPostponed = true,
        .cycleResetThresholdSeconds = 0,
        .nextSessionBaseSeconds = 500,
        .nextSessionAdjustedSeconds = 400,
    });

    QSignalSpy spy(&data, &AppData::changed);
    spy.clear();

    data.finalizePendingPostBreak(true, false);

    QCOMPARE(spy.count(), 1);
    QCOMPARE(data.schedule().secondsToNextBreak(), 400);
    QCOMPARE(data.smallBreaksBeforeBigBreak(), deps.preferences->bigAfter->get() - 1);
    QVERIFY(!data.postBreak().isActive());
  }
  // An active inhibitor at break end means the user is still using the machine;
  // resume normal scheduling instead of entering post-break idle.
  void inhibitor_at_break_end_resumes_instead_of_post_break_idle_data() {
    QTest::addColumn<bool>("autoCloseWindow");
    QTest::newRow("auto-close") << true;
    QTest::newRow("keep-window-open") << false;
  }
  void inhibitor_at_break_end_resumes_instead_of_post_break_idle() {
    QFETCH(bool, autoCloseWindow);
    deps.preferences->autoCloseWindowAfterSmallBreak->set(autoCloseWindow);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.breakNow();
    // User is idle (no real input) but an inhibitor is holding the display on.
    deps.idleTimer->setIdle(true);
    deps.idleTimer->setInhibited(true);
    app.advanceToBreakEnd();

    QCOMPARE(app.currentState(), AppState::Normal);
    QVERIFY(!app.trayData.pauseReasons);
  }
  // Without an inhibitor, an idle user still enters post-break idle.
  void no_inhibitor_idle_at_break_end_enters_post_break_idle() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.breakNow();
    deps.idleTimer->setIdle(true);
    deps.idleTimer->setInhibited(false);
    app.advanceToBreakEnd();

    QCOMPARE(app.currentState(), AppState::PostBreakIdle);
    QVERIFY(app.trayData.pauseReasons);
  }
  // Opting out of treatInhibitorAsActivity keeps the old pause-on-idle behavior.
  void inhibitor_ignored_when_pref_disabled() {
    deps.preferences->treatInhibitorAsActivity->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.breakNow();
    deps.idleTimer->setIdle(true);
    deps.idleTimer->setInhibited(true);
    app.advanceToBreakEnd();

    QCOMPARE(app.currentState(), AppState::PostBreakIdle);
    QVERIFY(app.trayData.pauseReasons);
  }
  void lock_screen_timer_running() {
    int autoScreenLockSeconds = 20;
    deps.preferences->autoScreenLock->set(autoScreenLockSeconds);

    NiceMock<DummyApp> app(deps);
    app.start();
    app.advance(app.trayData.secondsToNextBreak);

    EXPECT_CALL(app, doLockScreen()).Times(0);

    // Emitted by window manager when user is idle or force break
    app.advanceToForceBreakStart();

    QVERIFY(deps.screenLockTimer->isActive());
    QVERIFY(deps.screenLockTimer->isSingleShot());

    app.advanceToBreakEnd();

    EXPECT_CALL(app, doLockScreen()).Times(1);
    emit deps.screenLockTimer->timeout();

    QVERIFY(Mock::VerifyAndClearExpectations(&app));
  }
  void lock_screen_timer_not_run_if_not_configured() {
    int autoScreenLockSeconds = 0;
    deps.preferences->autoScreenLock->set(autoScreenLockSeconds);

    NiceMock<DummyApp> app(deps);
    app.start();
    app.advance(app.trayData.secondsToNextBreak);

    EXPECT_CALL(app, doLockScreen()).Times(0);
    app.advanceToForceBreakStart();
    QVERIFY(!deps.screenLockTimer->isActive());
    app.advanceToBreakEnd();

    QVERIFY(Mock::VerifyAndClearExpectations(&app));
  }
  void lock_screen_timer_not_running_if_not_idle() {
    int autoScreenLockSeconds = 20;
    deps.preferences->autoScreenLock->set(autoScreenLockSeconds);

    NiceMock<DummyApp> app(deps);
    app.start();
    app.advance(app.trayData.secondsToNextBreak);

    EXPECT_CALL(app, doLockScreen()).Times(0);
    // Emitted by window manager when user is idle or force break
    app.advanceToForceBreakStart();
    QVERIFY(deps.screenLockTimer->isActive());
    // User is idle during break
    deps.idleTimer->setIdle(true);
    app.advanceToBreakEnd();
    app.advance(1);
    // But is not idle immediately after break
    deps.idleTimer->setIdle(false);
    QVERIFY(!deps.screenLockTimer->isActive());
    QVERIFY(Mock::VerifyAndClearExpectations(&app));
  }
  void postpone_time() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.postpone(100);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak + 100);

    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToForceBreakStart();
    app.advance(deps.preferences->smallFor->get() + 1);
    // Break time is extended
    QVERIFY(app.trayData.isBreaking);

    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    // Next work session is shortened
    QCOMPARE(app.trayData.secondsToNextBreak,
             deps.preferences->smallEvery->get() - 100);
  }
  // We disallow postponing twice in the same work session
  void cannot_postpone_twice() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.postpone(100);
    app.postpone(100);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak + 100);
  }
  // We allow postponing across different work sessions
  void postpone_again_after_break() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.postpone(100);

    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak - 100);
    app.postpone(300);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak + 200);
  }
  void early_break_clears_effective_postpone() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.postpone(100);
    QVERIFY(app.trayData.isPostponing);

    app.breakNow();

    QVERIFY(app.trayData.isBreaking);
    QVERIFY(!app.trayData.isPostponing);
  }
  // During the pause, the count down should not change, and state should be reflected
  void pause_break_on_idle() {
    NiceMock<DummyApp> app(deps);
    app.start();

    deps.idleTimer->setIdle(true);
    QCOMPARE(app.trayData.pauseReasons, PauseReason::Idle);
    // Countdown stopped
    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak);

    deps.idleTimer->setIdle(false);
    QVERIFY(!app.trayData.pauseReasons);
    // Countdown resumed
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak - 1);
  }
  // Similar to `pause_break_on_idle`, but for other reasons
  void pause_break_on_request_data() {
    QTest::addColumn<PauseReason>("reason");
    QTest::newRow("battery") << PauseReason::OnBattery;
    QTest::newRow("program") << PauseReason::AppOpen;
  };
  void pause_break_on_request() {
    deps.preferences->pauseOnBattery->set(true);
    NiceMock<DummyApp> app(deps);
    app.start();

    QFETCH(PauseReason, reason);
    emit deps.systemMonitor->pauseRequested(reason);
    QCOMPARE(app.trayData.pauseReasons, reason);
    // Countdown stopped
    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak);

    emit deps.systemMonitor->resumeRequested(reason);
    QVERIFY(!app.trayData.pauseReasons);
    // Countdown resumed
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak - 1);
  }
  // A program-monitor pause (e.g. a meeting app running) must be honored even
  // while a break is postponed. Otherwise the postponed break fires mid-meeting.
  void pause_request_honored_during_postpone() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.postpone(120);
    QVERIFY(app.trayData.isPostponing);

    emit deps.systemMonitor->pauseRequested(PauseReason::AppOpen);
    QCOMPARE(app.trayData.pauseReasons, PauseReason::AppOpen);
    // Countdown frozen while paused: the postponed break must not fire.
    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.advance(secondsToNextBreak + 10);
    QVERIFY(!app.trayData.isBreaking);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak);

    // Once the meeting app closes, the countdown resumes and the postponed break
    // fires.
    emit deps.systemMonitor->resumeRequested(PauseReason::AppOpen);
    QVERIFY(!app.trayData.pauseReasons);
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak - 1);
  }

  // Postpone keeps its promise: a transient idle right after postponing must
  // not swallow the postponed break (breaks still fire on schedule).
  void idle_ignored_during_postpone() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.postpone(120);
    QVERIFY(app.trayData.isPostponing);

    deps.idleTimer->setIdle(true);
    // Idle pause is suppressed during postpone
    QCOMPARE(app.trayData.pauseReasons.toInt(), 0);
    // Countdown keeps running and the postponed break fires
    app.advance(app.trayData.secondsToNextBreak);
    QVERIFY(app.trayData.isBreaking);
  }

  // A pause that runs past the end of the would-be (postponed) break fulfills the
  // postpone: the break is considered taken, the postpone is discarded, and the
  // countdown restarts fresh.
  void long_pause_during_postpone_voids_postpone() {
    deps.preferences->pauseOnBattery->set(true);
    deps.preferences->smallEvery->set(100);
    deps.preferences->smallFor->set(10);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.postpone(50);  // countdown 150
    QVERIFY(app.trayData.isPostponing);

    emit deps.systemMonitor->pauseRequested(PauseReason::OnBattery);
    app.advance(200);  // pause covers the break ending at 150 + 10
    emit deps.systemMonitor->resumeRequested(PauseReason::OnBattery);

    QVERIFY(!app.trayData.isPostponing);
    QCOMPARE(app.trayData.secondsToNextBreak, 100);
  }
  // ...and the postponed break's penalties (extended break, shrunk next session)
  // must not apply to the next cycle.
  void long_pause_during_postpone_clears_penalties() {
    deps.preferences->pauseOnBattery->set(true);
    deps.preferences->smallEvery->set(100);
    deps.preferences->smallFor->set(10);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.postpone(50);
    emit deps.systemMonitor->pauseRequested(PauseReason::OnBattery);
    app.advance(200);
    emit deps.systemMonitor->resumeRequested(PauseReason::OnBattery);
    QCOMPARE(app.trayData.secondsToNextBreak, 100);

    // Next break completes with a full session: no shrink from the discarded postpone
    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.secondsToNextBreak, 100);
  }
  // A short pause that does not cover the postponed break keeps the postpone intact.
  void short_pause_during_postpone_keeps_postpone() {
    deps.preferences->pauseOnBattery->set(true);
    deps.preferences->smallEvery->set(100);
    deps.preferences->smallFor->set(10);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.postpone(50);  // countdown 150
    emit deps.systemMonitor->pauseRequested(PauseReason::OnBattery);
    app.advance(100);  // 100 < 150 + 10: break not covered
    emit deps.systemMonitor->resumeRequested(PauseReason::OnBattery);

    QVERIFY(app.trayData.isPostponing);
    QCOMPARE(app.trayData.secondsToNextBreak, 150);
  }
  // A long sleep during postpone has the same effect as a long pause: the postpone
  // is discarded and the countdown restarts fresh.
  void long_sleep_during_postpone_voids_postpone() {
    deps.preferences->smallEvery->set(100);
    deps.preferences->smallFor->set(10);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.postpone(50);                          // countdown 150
    emit deps.systemMonitor->sleepEnded(200);  // covers the break ending at 160

    QVERIFY(!app.trayData.isPostponing);
    QCOMPARE(app.trayData.secondsToNextBreak, 100);
  }

  // If paused for a long time, we consider the user has taken a break and reset timer.
  void reset_countdown_after_pause() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    app.advance(100);

    int smallBreaksBeforeBigBreak = app.trayData.smallBreaksBeforeBigBreak;
    deps.idleTimer->setIdle(true);
    app.advance(deps.preferences->resetAfterPause->get() + 1);
    deps.idleTimer->setIdle(false);

    int smallEvery = deps.preferences->smallEvery->get();
    // Countdown resumed and the time has been reset
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery - 1);
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, smallBreaksBeforeBigBreak);

    // Subsequent short idles should not be affected
    deps.idleTimer->setIdle(true);
    app.advance(1);
    deps.idleTimer->setIdle(false);
    // Since the time ellapsed is in paused state, the time should not change
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery - 1);
  }
  // If paused for even longer, we also reset the break cycle
  void reset_cycle_after_pause() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.advance(100);
    deps.idleTimer->setIdle(true);
    app.advance(deps.preferences->resetCycleAfterPause->get() + 1);
    deps.idleTimer->setIdle(false);

    int smallEvery = deps.preferences->smallEvery->get();
    // Countdown resumed and the time has been reset
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery - 1);
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);
  }
  /* If the time when the pause starts is close to the next break, and the pause is
  not
   * long enough to reset the break countdown, the break will start very soon after
   the
   * pause ends. Having a break right after coming back from other things can be
   * annoying. Therefore, we consider the case where there were no pauses. If the
   break
   * happens and finishes before the user comes back, we just consider the break
   has
   * ended and starts the next cycle.
   */
  void avoid_immediate_break_after_pause() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int smallEvery = deps.preferences->smallEvery->get();
    app.advance(smallEvery - 30);
    deps.idleTimer->setIdle(true);
    // Enough time paused
    app.advance(30 + deps.preferences->smallFor->get() + 10);
    deps.idleTimer->setIdle(false);
    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery);
  }
  /* Similar to the previous `avoid_immediate_break_after_pause`, but if the pause
  is so
   * short that we should not consider that the user has taken the break, we will
   not
   * reset the break cycle.
   */
  void do_not_avoid_immediate_break_after_pause_too_short() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int smallEvery = deps.preferences->smallEvery->get();
    app.advance(smallEvery - 30);
    deps.idleTimer->setIdle(true);
    // Not enough time paused
    app.advance(30 + deps.preferences->smallFor->get() - 10);
    deps.idleTimer->setIdle(false);
    QCOMPARE(app.trayData.secondsToNextBreak, 30);
  }
  /* During a big break, if the user is not ready for a big break, he/she can choose to
   * take a small break instead for now, and the next break is still a big break.
   * Technically, this will close the current big break windows and create new small
   * break windows.
   */
  void take_small_break_instead() {
    deps.preferences->pauseOnBattery->set(true);
    NiceMock<DummyApp> app(deps);
    app.start();

    // Verify we successfully triggered a big break
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Big, _, _, _)).Times(1);
    app.bigBreakNow();
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));

    // Triggering small break instead is deleting previous windows and creating new ones
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    EXPECT_CALL(*deps.breakWindows, destroy()).Times(1);
    app.smallBreakInstead();
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    // We are still in breaking mode. Verify the break has not magically ended.
    QVERIFY(app.trayData.isBreaking);

    app.advanceToBreakEnd();
    QVERIFY(!app.trayData.isBreaking);
    // The next break is a big break
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 0);
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get());
  }
  // User should be able to postpone the break while its happening
  void postpone_while_break() {
    deps.preferences->pauseOnBattery->set(true);
    NiceMock<DummyApp> app(deps);
    app.start();

    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.breakNow();
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));

    EXPECT_CALL(*deps.breakWindows, destroy()).Times(1);
    app.postpone(100);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    QCOMPARE(app.trayData.secondsToNextBreak, 100);
  }
  // We should end the break if the pause happens during the break
  void pause_while_break() {
    deps.preferences->pauseOnBattery->set(true);
    NiceMock<DummyApp> app(deps);
    app.start();

    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.breakNow();
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));

    EXPECT_CALL(*deps.breakWindows, destroy()).Times(1);
    emit deps.systemMonitor->pauseRequested(PauseReason::OnBattery);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
  }
  // Ignore pauses caused by idles during the break
  void idle_while_break() {
    NiceMock<DummyApp> app(deps);
    app.start();

    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.breakNow();
    EXPECT_CALL(*deps.breakWindows, destroy()).Times(0);
    deps.idleTimer->setIdle(true);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    QVERIFY(app.trayData.isBreaking);
    QCOMPARE(app.trayData.secondsToNextBreak, 0);
  }
  // If the system went asleep during the break, after awaken, the break windows should
  // be closed.
  void sleep_end_while_break() {
    NiceMock<DummyApp> app(deps);
    app.start();

    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.breakNow();
    EXPECT_CALL(*deps.breakWindows, destroy()).Times(1);
    emit deps.systemMonitor->sleepEnded(1);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    QVERIFY(!app.trayData.isBreaking);
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get());
  }
  void short_sleep_does_nothing() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.advance(1);
    emit deps.systemMonitor->sleepEnded(1);

    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get() - 1);
  }
  void long_sleep_resets_cycle() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.advance(1);
    emit deps.systemMonitor->sleepEnded(deps.preferences->resetCycleAfterPause->get() +
                                        1);

    // Long sleep should reset both countdown and cycle
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get());
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);
  }
  // Meeting mode: break countdown frozen during meeting
  void meeting_freezes_break_countdown() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.startMeeting(3600, "standup");
    QVERIFY(app.trayData.isInMeeting);

    app.advance(10);
    // Break countdown should not change
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak);
  }
  // Meeting countdown ticks and shows end prompt at 60s remaining
  void meeting_countdown_ticks() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(65, "standup");
    QCOMPARE(app.trayData.meetingSecondsRemaining, 65);

    app.advance(3);
    QCOMPARE(app.trayData.meetingSecondsRemaining, 62);

    // No prompt yet (remaining > 60)
    EXPECT_CALL(*deps.meetingPrompt, showEndPrompt()).Times(0);
    app.advance(1);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.meetingPrompt));

    // Prompt appears when remaining <= 60
    EXPECT_CALL(*deps.meetingPrompt, showEndPrompt()).Times(1);
    app.advance(1);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.meetingPrompt));
    QCOMPARE(app.trayData.meetingSecondsRemaining, 60);
    QVERIFY(app.trayData.isInMeeting);
  }
  // Meeting auto-exits to normal when time runs out
  void meeting_prompt_timeout() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(5, "standup");
    app.advance(4);  // remaining = 1, prompt showing
    QVERIFY(app.trayData.isInMeeting);

    app.advance(1);  // remaining = 0, auto-exit to normal
    QVERIFY(!app.trayData.isInMeeting);
    QVERIFY(!app.trayData.isBreaking);
    QCOMPARE(app.currentState(), AppState::Normal);
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 0);  // next break is big
  }
  // Idle during meeting stays in Normal
  void meeting_blocks_idle_pause() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(3600, "standup");
    deps.idleTimer->setIdle(true);

    QCOMPARE(app.currentState(), AppState::Meeting);
    QVERIFY(!app.trayData.pauseReasons);
  }
  // System pause during meeting stays in Meeting
  void meeting_blocks_system_pause() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(3600, "standup");
    emit deps.systemMonitor->pauseRequested(PauseReason::OnBattery);

    QCOMPARE(app.currentState(), AppState::Meeting);
  }
  // endMeetingBreakLater sets 5min countdown and big break
  void meeting_end_break_soon() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(5, "standup");
    app.advance(4);  // remaining = 1, prompt showing
    app.endMeetingBreakLater(300);

    QVERIFY(!app.trayData.isInMeeting);
    QCOMPARE(app.trayData.secondsToNextBreak, 300);
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 0);  // next is big
  }
  // endMeetingBreakNow triggers immediate big break
  void meeting_end_break_now() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(5, "standup");
    app.advance(4);  // remaining = 1, prompt showing
    app.endMeetingBreakNow();

    QVERIFY(!app.trayData.isInMeeting);
    QVERIFY(app.trayData.isBreaking);
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 0);  // big break
  }
  // Extend meeting during prompt closes prompt and resets
  void meeting_extend() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(5, "standup");
    app.advance(4);  // remaining = 1, prompt showing

    EXPECT_CALL(*deps.meetingPrompt, closeEndPrompt()).Times(1);
    app.extendMeeting(1800);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.meetingPrompt));
    QCOMPARE(app.trayData.meetingSecondsRemaining, 1801);  // 1 + 1800
    QVERIFY(app.trayData.isInMeeting);
  }
  // Sleep during meeting adjusts countdown
  void sleep_during_meeting_adjusts() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(3600, "standup");
    emit deps.systemMonitor->sleepEnded(600);

    QCOMPARE(app.trayData.meetingSecondsRemaining, 3000);
    QVERIFY(app.trayData.isInMeeting);
  }
  // Sleep past meeting end resets cycle
  void sleep_past_meeting_resets_cycle() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(600, "standup");
    emit deps.systemMonitor->sleepEnded(1200);

    QVERIFY(!app.trayData.isInMeeting);
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get());
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);
  }
  // Short sleep during prompt phase subtracts from remaining
  void sleep_during_awaiting_short() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(30, "standup");
    app.advance(1);  // remaining = 29, prompt showing

    emit deps.systemMonitor->sleepEnded(10);
    QCOMPARE(app.trayData.meetingSecondsRemaining, 19);  // 29 - 10
    QVERIFY(app.trayData.isInMeeting);
  }
  // Long sleep during prompt phase ends meeting
  void sleep_during_awaiting_long() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(30, "standup");
    app.advance(1);  // remaining = 29

    emit deps.systemMonitor->sleepEnded(29 + deps.preferences->bigFor->get());
    QVERIFY(!app.trayData.isInMeeting);
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get());
  }
  // When big breaks are disabled, all breaks should be small
  void big_break_disabled_all_breaks_small() {
    deps.preferences->bigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    int numberOfBreaks = deps.preferences->bigAfter->get();
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _))
        .Times(numberOfBreaks);
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Big, _, _, _)).Times(0);
    for (int j = 0; j < numberOfBreaks; j++) {
      app.advance(app.trayData.secondsToNextBreak);
      app.advanceToBreakEnd();
    }
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
  }
  // When big breaks are disabled, tray should not show big break info
  void big_break_disabled_tray_info() {
    deps.preferences->bigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    QVERIFY(!app.trayData.bigBreakEnabled);
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, -1);
  }
  // When big breaks are disabled, bigBreakNow should trigger a small break
  void big_break_disabled_big_break_now() {
    deps.preferences->bigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.bigBreakNow();
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
  }
  // When big breaks are disabled, meeting end should not force a big break
  void big_break_disabled_meeting_end() {
    deps.preferences->bigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(5, "standup");
    app.advance(4);  // remaining = 1, prompt showing
    app.endMeetingBreakNow();

    QVERIFY(!app.trayData.isInMeeting);
    QVERIFY(app.trayData.isBreaking);
    // Should NOT be a big break since big breaks are disabled
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, -1);
  }
  // Meeting allowed during postpone, clears postpone data
  void meeting_allowed_during_postpone() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.postpone(100);
    QVERIFY(app.trayData.isPostponing);

    app.startMeeting(3600, "standup");
    QVERIFY(app.trayData.isInMeeting);
    QVERIFY(!app.trayData.isPostponing);

    // After meeting ends, no postpone penalties should apply
    app.advance(3600);
    QVERIFY(!app.trayData.isInMeeting);
    // Next break should use normal interval, no shrinkage from postpone
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get());
  }
  // Meeting during break exits break
  void meeting_during_break_exits_break() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.breakNow();
    QVERIFY(app.trayData.isBreaking);

    app.startMeeting(3600, "standup");
    QVERIFY(app.trayData.isInMeeting);
    QVERIFY(!app.trayData.isBreaking);
  }
  // TrayData reflects meeting state
  void meeting_tray_data() {
    NiceMock<DummyApp> app(deps);
    app.start();

    QVERIFY(!app.trayData.isInMeeting);
    QCOMPARE(app.trayData.meetingSecondsRemaining, 0);

    app.startMeeting(100, "standup");
    QVERIFY(app.trayData.isInMeeting);
    QCOMPARE(app.trayData.meetingSecondsRemaining, 100);

    app.advance(50);
    QCOMPARE(app.trayData.meetingSecondsRemaining, 50);
  }
  // Sleep end should not change the pause state
  void sleep_end_while_idle_paused() {
    NiceMock<DummyApp> app(deps);
    app.start();

    deps.idleTimer->setIdle(true);
    emit deps.systemMonitor->sleepEnded(30);

    // Should remain paused
    QCOMPARE(app.trayData.pauseReasons, PauseReason::Idle);
  }
  void sleep_end_while_normal_splits_span_in_db() {
    NiceMock<DummyApp> app(deps);
    app.start();

    QDateTime fakeStart = QDateTime::currentDateTimeUtc().addSecs(-7200);
    QString fakeStartStr = fakeStart.toUTC().toString(Qt::ISODate);

    app.closeCurrentSpan();
    app.openCurrentSpan("normal", {}, fakeStart);

    emit deps.systemMonitor->sleepEnded(3600);

    QSqlQuery normalQuery;
    normalQuery.prepare(
        "SELECT ended_at FROM spans WHERE type = 'normal' AND started_at = ?");
    normalQuery.addBindValue(fakeStartStr);
    QVERIFY(normalQuery.exec());
    QVERIFY(normalQuery.next());
    QString normalEndedAt = normalQuery.value(0).toString();
    QVERIFY(!normalEndedAt.isEmpty());

    QSqlQuery sleepQuery;
    QVERIFY(sleepQuery.exec(
        "SELECT started_at, ended_at FROM spans WHERE type = 'sleep' ORDER BY id DESC "
        "LIMIT 1"));
    QVERIFY(sleepQuery.next());
    QString sleepStartedAt = sleepQuery.value(0).toString();
    QString sleepEndedAt = sleepQuery.value(1).toString();

    QCOMPARE(normalEndedAt, sleepStartedAt);
    QVERIFY(!sleepEndedAt.isEmpty());

    QSqlQuery openNormalQuery;
    QVERIFY(openNormalQuery.exec(
        "SELECT COUNT(*) FROM spans WHERE type = 'normal' AND ended_at IS NULL"));
    QVERIFY(openNormalQuery.next());
    QCOMPARE(openNormalQuery.value(0).toInt(), 1);
  }
  void sleep_end_while_normal_omits_slept_time_from_usage_stats() {
    NiceMock<DummyApp> app(deps);
    app.start();

    QDateTime now = QDateTime::currentDateTimeUtc();
    QDateTime fakeStart = now.addSecs(-7200);

    app.closeCurrentSpan();
    app.openCurrentSpan("normal", {}, fakeStart);

    emit deps.systemMonitor->sleepEnded(3600);

    QDate from = fakeStart.toLocalTime().date();
    QDate to = now.toLocalTime().date();
    int trackedSeconds = sumTrackedSeconds(deps.db, from, to);

    QVERIFY2(trackedSeconds >= 3500, "tracked time should keep the pre-sleep hour");
    QVERIFY2(trackedSeconds < 5400, "tracked time should not include the slept hour");
  }
  void sleep_during_post_break_idle_counts_as_inactivity() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int smallEvery = deps.preferences->smallEvery->get();
    app.postpone(100);
    app.advance(app.trayData.secondsToNextBreak);
    deps.idleTimer->setIdle(true);
    app.advanceToBreakEnd();

    emit deps.systemMonitor->sleepEnded(deps.preferences->resetCycleAfterPause->get() +
                                        1);
    deps.idleTimer->setIdle(false);

    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery);
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);
  }
  // Multiple pause reasons should work
  void more_than_one_pause_reasons() {
    deps.preferences->pauseOnBattery->set(true);
    NiceMock<DummyApp> app(deps);
    app.start();

    emit deps.systemMonitor->pauseRequested(PauseReason::OnBattery);
    QVERIFY(app.trayData.pauseReasons.testFlags(PauseReason::OnBattery));

    deps.idleTimer->setIdle(true);
    QVERIFY(app.trayData.pauseReasons.testFlags(PauseReason::Idle |
                                                PauseReason::OnBattery));
    // Countdown stopped
    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak);

    deps.idleTimer->setIdle(false);
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak);
    QVERIFY(app.trayData.pauseReasons.testFlag(PauseReason::OnBattery));

    emit deps.systemMonitor->resumeRequested(PauseReason::OnBattery);
    QVERIFY(!app.trayData.pauseReasons);

    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak - 1);
    QVERIFY(!app.trayData.pauseReasons);
  }
  void pause_spans_rotate_from_paused_to_away_and_back() {
    deps.preferences->pauseOnBattery->set(true);
    NiceMock<DummyApp> app(deps);
    app.start();

    emit deps.systemMonitor->pauseRequested(PauseReason::OnBattery);
    deps.idleTimer->setIdle(true);
    deps.idleTimer->setIdle(false);
    emit deps.systemMonitor->resumeRequested(PauseReason::OnBattery);

    QSqlQuery query;
    QVERIFY(query.exec(R"(
      SELECT type,
             json_extract(data, '$.reasons[0]'),
             json_extract(data, '$.reasons[1]')
      FROM spans
      WHERE type IN ('paused', 'away')
      ORDER BY id
    )"));

    QVERIFY(query.next());
    QCOMPARE(query.value(0).toString(), QString("paused"));
    QCOMPARE(query.value(1).toString(), QString("idle"));
    QCOMPARE(query.value(2).toString(), QString("on-battery"));

    QVERIFY(query.next());
    QCOMPARE(query.value(0).toString(), QString("away"));
    QCOMPARE(query.value(1).toString(), QString("idle"));
    QCOMPARE(query.value(2).toString(), QString("on-battery"));

    QVERIFY(query.next());
    QCOMPARE(query.value(0).toString(), QString("paused"));
    QCOMPARE(query.value(1).toString(), QString("on-battery"));
    QVERIFY(!query.value(2).isValid() || query.value(2).toString().isEmpty());

    QVERIFY(!query.next());
  }
  /* We disallow pausing duing postponing because:
   * 1. this will almost not happen, so we can simplify things.
   * 2. if this happens, postponing should mean continuous working.
   */
  void no_pause_during_break() {
    NiceMock<DummyApp> app(deps);
    app.start();

    int smallEvery = deps.preferences->smallEvery->get();
    app.postpone(1000);

    deps.idleTimer->setIdle(true);
    app.advance(10);
    deps.idleTimer->setIdle(false);
    app.advance(1);

    QCOMPARE(app.trayData.secondsToNextBreak, smallEvery + 989);
  }
  // End meeting early with immediate break
  void meeting_end_early_break_now() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(3600, "standup");
    app.advance(600);  // 10 minutes in
    QVERIFY(app.trayData.isInMeeting);
    QCOMPARE(app.trayData.meetingSecondsRemaining, 3000);

    app.endMeetingBreakNow();
    QVERIFY(!app.trayData.isInMeeting);
    QVERIFY(app.trayData.isBreaking);
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 0);  // big break
  }
  // Focus mode entry break forces full-screen (flashFor=0)
  void focus_mode_entry_break() {
    NiceMock<DummyApp> app(deps);
    app.start();

    // Set focus schedule
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);

    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.startFocus(4, "deep work");
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    QVERIFY(app.trayData.isBreaking);
    QVERIFY(app.trayData.isFocusMode);

    // Entry break should be force break immediately (flashFor=0),
    // so full screen should show without any ticks
    EXPECT_CALL(*deps.breakWindows, showFullScreen()).Times(1);
    app.advance(1);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
  }
  // Each break decrements cycles and uses effective schedule
  void focus_mode_cycles_decrement() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startFocus(3, "deep work");
    QVERIFY(app.trayData.isFocusMode);
    QCOMPARE(app.trayData.focusCyclesRemaining, 3);

    // Finish entry break — entry break does not decrement cycles
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.focusCyclesRemaining, 3);
    // Uses focus schedule (600s not default 1200s)
    QCOMPARE(app.trayData.secondsToNextBreak, 600);
  }
  // Focus deactivates when cycles reach 0, normal schedule resumes
  void focus_mode_exit_on_last_break() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startFocus(2, "deep work");
    // Entry break — does not decrement cycles
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.focusCyclesRemaining, 2);
    QVERIFY(app.trayData.isFocusMode);

    // First real break
    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.focusCyclesRemaining, 1);
    QVERIFY(app.trayData.isFocusMode);

    // Second real break
    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    // Focus mode should now be deactivated
    QVERIFY(!app.trayData.isFocusMode);
    // Normal schedule resumes
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get());
  }
  // EndFocus sets cycles=1, triggers break, exits focus
  void focus_mode_early_exit() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startFocus(4, "deep work");
    // Entry break does not decrement
    app.advanceToBreakEnd();
    QVERIFY(app.trayData.isFocusMode);
    QCOMPARE(app.trayData.focusCyclesRemaining, 4);

    // End focus early
    app.endFocus();
    QVERIFY(app.trayData.isBreaking);
    // Finish the exit break
    app.advanceToBreakEnd();
    QVERIFY(!app.trayData.isFocusMode);
    QCOMPARE(app.trayData.secondsToNextBreak, deps.preferences->smallEvery->get());
  }
  // Postpone prevented during focus
  void focus_mode_no_postpone() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startFocus(3, "deep work");
    app.advanceToBreakEnd();
    int secondsToNextBreak = app.trayData.secondsToNextBreak;

    app.postpone(100);
    // Postpone should be prevented
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak);
  }
  // Starting meeting clears focus
  void focus_mode_meeting_ends_focus() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startFocus(3, "deep work");
    app.advanceToBreakEnd();
    QVERIFY(app.trayData.isFocusMode);

    app.startMeeting(3600, "standup");
    QVERIFY(!app.trayData.isFocusMode);
    QVERIFY(app.trayData.isInMeeting);
  }
  // Focus keeps a separate cycle; normal-cycle progress resumes unchanged after focus
  void focus_mode_shared_cycle() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    // Start at cycle 1 with bigAfter=3, so 2 small breaks before big
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);

    app.startFocus(2, "deep work");
    // Entry break advances focus cycle, not normal cycle
    app.advanceToBreakEnd();
    // First real focus break (cycle decrement: 2→1)
    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    QVERIFY(app.trayData.isFocusMode);
    // Second real focus break (cycle decrement: 1→0)
    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    // Focus ended, back to normal schedule
    QVERIFY(!app.trayData.isFocusMode);
    // Normal cycle progress resumes where it was before focus started.
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak,
             deps.preferences->bigAfter->get() - 1);
  }
  void focus_mode_big_break_cycle_is_independent() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(true);
    deps.preferences->focusBigAfter->set(2);
    deps.preferences->focusBigFor->set(20);
    NiceMock<DummyApp> app(deps);
    app.start();

    int normalSmallBreaksBeforeBig = app.trayData.smallBreaksBeforeBigBreak;

    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.startFocus(1, "deep work");
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    app.advanceToBreakEnd();

    // Focus entry break advances the focus-only cycle, so the next focus break is big.
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 0);

    EXPECT_CALL(*deps.breakWindows, create(BreakType::Big, _, _, _)).Times(1);
    app.advance(app.trayData.secondsToNextBreak);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    app.advanceToBreakEnd();

    QVERIFY(!app.trayData.isFocusMode);
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, normalSmallBreaksBeforeBig);
  }
  void reducing_big_after_keeps_completed_small_break_progress() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 1);

    deps.preferences->bigAfter->set(2);

    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 0);
  }
  void increasing_big_after_keeps_completed_small_break_progress() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.advance(app.trayData.secondsToNextBreak);
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 1);

    deps.preferences->bigAfter->set(4);

    QCOMPARE(app.trayData.smallBreaksBeforeBigBreak, 2);
  }
  // Tray data reflects focus state
  void focus_mode_tray_info() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    QVERIFY(!app.trayData.isFocusMode);
    QCOMPARE(app.trayData.focusCyclesRemaining, 0);
    QCOMPARE(app.trayData.focusTotalCycles, 0);

    app.startFocus(4, "deep work");
    QVERIFY(app.trayData.isFocusMode);
    QCOMPARE(app.trayData.focusCyclesRemaining, 4);
    QCOMPARE(app.trayData.focusTotalCycles, 4);

    // Entry break does not decrement cycles
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.focusCyclesRemaining, 4);
    QCOMPARE(app.trayData.focusTotalCycles, 4);
  }
  // Focus during break restarts break with focus duration
  void focus_during_break_restarts_with_focus_duration() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    // Start a normal break
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.breakNow();
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    QVERIFY(app.trayData.isBreaking);

    // Enter focus mode during the break — break should restart
    EXPECT_CALL(*deps.breakWindows, destroy()).Times(1);
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, 10, _)).Times(1);
    app.startFocus(3, "deep work");
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    QVERIFY(app.trayData.isBreaking);
    QVERIFY(app.trayData.isFocusMode);

    // Should behave like focus entry break: immediate full-screen (flashFor=0)
    EXPECT_CALL(*deps.breakWindows, showFullScreen()).Times(1);
    app.advance(1);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
  }
  // Focus during break after postpone should work and clear postpone data
  void focus_during_break_after_postpone() {
    deps.preferences->focusSmallEvery->set(600);
    deps.preferences->focusSmallFor->set(10);
    deps.preferences->focusBigBreakEnabled->set(false);
    NiceMock<DummyApp> app(deps);
    app.start();

    // Postpone and let break come naturally
    app.postpone(100);
    QVERIFY(app.trayData.isPostponing);
    app.advance(app.trayData.secondsToNextBreak);
    QVERIFY(app.trayData.isBreaking);

    // Enter focus during this break-after-postpone
    EXPECT_CALL(*deps.breakWindows, destroy()).Times(1);
    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, 10, _)).Times(1);
    app.startFocus(3, "deep work");
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    QVERIFY(app.trayData.isFocusMode);
    QVERIFY(!app.trayData.isPostponing);

    // Finish focus entry break (no cycle decrement), next session should not have
    // postpone penalties
    app.advanceToBreakEnd();
    QCOMPARE(app.trayData.secondsToNextBreak, 600);
  }
  // Unknown monitor triggers pause via system monitor
  void unknown_monitor_pauses() {
    NiceMock<DummyApp> app(deps);
    app.start();

    emit deps.systemMonitor->pauseRequested(PauseReason::UnknownMonitor);
    QCOMPARE(app.trayData.pauseReasons, PauseReason::UnknownMonitor);
    // Countdown stopped
    int secondsToNextBreak = app.trayData.secondsToNextBreak;
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak);

    emit deps.systemMonitor->resumeRequested(PauseReason::UnknownMonitor);
    QVERIFY(!app.trayData.pauseReasons);
    // Countdown resumed
    app.advance(1);
    QCOMPARE(app.trayData.secondsToNextBreak, secondsToNextBreak - 1);
  }
  // Unknown monitor during break should exit break (non-idle pause)
  void unknown_monitor_during_break() {
    NiceMock<DummyApp> app(deps);
    app.start();

    EXPECT_CALL(*deps.breakWindows, create(BreakType::Small, _, _, _)).Times(1);
    app.breakNow();
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));

    EXPECT_CALL(*deps.breakWindows, destroy()).Times(1);
    emit deps.systemMonitor->pauseRequested(PauseReason::UnknownMonitor);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.breakWindows));
    QVERIFY(!app.trayData.isBreaking);
  }
  // Extend meeting before it ends
  void meeting_extend_before_end() {
    NiceMock<DummyApp> app(deps);
    app.start();

    app.startMeeting(600, "standup");
    app.advance(100);
    QCOMPARE(app.trayData.meetingSecondsRemaining, 500);

    EXPECT_CALL(*deps.meetingPrompt, showEndPrompt()).Times(0);
    app.extendMeeting(900);  // +15 min
    QCOMPARE(app.trayData.meetingSecondsRemaining, 1400);
    QVERIFY(app.trayData.isInMeeting);
    QVERIFY(Mock::VerifyAndClearExpectations(deps.meetingPrompt));
  }
};

QTEST_MAIN(TestApp)
#include "test-app.moc"
