import pytest
import importlib.metadata

from datetime import date, timedelta
from pathlib import Path

import bouquin.main_window as mwmod
from bouquin.main_window import MainWindow
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.settings import get_settings
from bouquin.key_prompt import KeyPrompt
from bouquin.db import DBConfig, DBManager
from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect
from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog
from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent

from unittest.mock import Mock, patch

import bouquin.version_check as version_check


def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", True)
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    date = QDate.currentDate().toString("yyyy-MM-dd")
    w._load_selected_date(date)
    w.editor.from_markdown("hello **world**")
    w._on_text_changed()
    qtbot.wait(5500)  # let the 5s autosave QTimer fire
    assert "world" in fresh_db.get_entry(date)

    w.search.search.setText("world")
    qtbot.wait(50)
    assert not w.search.results.isHidden()

    w._sync_toolbar()
    w._adjust_day(-1)  # previous day
    w._adjust_day(+1)  # next day

    # Auto-accept the unlock KeyPrompt with the correct key
    def _auto_accept_keyprompt():
        for wdg in QApplication.topLevelWidgets():
            if isinstance(wdg, KeyPrompt):
                wdg.key_entry.setText(tmp_db_cfg.key)
                wdg.accept()

    w._enter_lock()
    QTimer.singleShot(0, _auto_accept_keyprompt)
    w._on_unlock_clicked()
    qtbot.wait(50)  # let the nested event loop process the acceptance


def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/move_todos", True)
    s.setValue("ui/theme", "light")

    y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
    fresh_db.save_new_version(y, "- [ ] carry me\n- [x] done", "seed")

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))

    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    w._load_unchecked_todos()

    assert "carry me" in w.editor.to_markdown()
    y_txt = fresh_db.get_entry(y)
    assert "carry me" not in y_txt or "- [ ]" not in y_txt


def test_open_docs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Force QDesktopServices.openUrl to fail so the warning path executes
    called = {"docs": False, "bugs": False}

    def fake_open(url):
        # return False to force warning path
        return False

    mwmod.QDesktopServices.openUrl = fake_open  # minimal monkeypatch

    class DummyMB:
        @staticmethod
        def warning(parent, title, text, *rest):
            t = str(text)
            if "wiki" in t:
                called["docs"] = True
            return 0

    monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True)  # capture warnings

    w._open_docs()
    assert called["docs"]


def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
    # Seed some content
    fresh_db.save_new_version("2001-01-01", "alpha", "n1")
    fresh_db.save_new_version("2001-01-02", "beta", "n2")

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    from bouquin.settings import get_settings

    s = get_settings()
    s.setValue("db/default_db", str(fresh_db.cfg.path))
    s.setValue("db/key", fresh_db.cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Save as Markdown without extension -> should append .md and write file
    dest1 = tmp_path / "export_one"  # no suffix
    monkeypatch.setattr(
        mwmod.QFileDialog,
        "getSaveFileName",
        staticmethod(lambda *a, **k: (str(dest1), "Markdown (*.md)")),
    )

    # Use real QMessageBox class; just force decisions and silence popups
    monkeypatch.setattr(
        mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
    )
    monkeypatch.setattr(
        mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
    )
    # Critical should never trigger in the success path
    monkeypatch.setattr(
        mwmod.QMessageBox,
        "critical",
        staticmethod(
            lambda *a, **k: (_ for _ in ()).throw(AssertionError("Unexpected critical"))
        ),
        raising=False,
    )

    w._export()
    assert dest1.with_suffix(".md").exists()

    # Now force an exception during export to hit error branch (patch the window's DB)
    def boom():
        raise RuntimeError("explode")

    monkeypatch.setattr(w.db, "get_all_entries", boom, raising=False)

    # Different filename to avoid overwriting
    dest2 = tmp_path / "export_two"
    monkeypatch.setattr(
        mwmod.QFileDialog,
        "getSaveFileName",
        staticmethod(lambda *a, **k: (str(dest2), "CSV (*.csv)")),
    )

    errs = {"hit": False}
    monkeypatch.setattr(
        mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
    )
    monkeypatch.setattr(
        mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
    )
    monkeypatch.setattr(
        mwmod.QMessageBox,
        "critical",
        staticmethod(lambda *a, **k: errs.__setitem__("hit", True) or 0),
        raising=False,
    )
    w._export()
    assert errs["hit"]


def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))

    # wire DB settings the window reads
    from bouquin.settings import get_settings

    s = get_settings()
    s.setValue("db/default_db", str(fresh_db.cfg.path))
    s.setValue("db/key", fresh_db.cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")

    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Pretend user picked a filename with no suffix -> .db should be appended
    dest = tmp_path / "backupfile"
    monkeypatch.setattr(
        mwmod.QFileDialog,
        "getSaveFileName",
        staticmethod(lambda *a, **k: (str(dest), "SQLCipher (*.db)")),
        raising=False,
    )

    # Avoid any modal dialogs and record the success message
    hit = {"info": False, "text": None}
    monkeypatch.setattr(
        mwmod.QMessageBox,
        "information",
        staticmethod(
            lambda parent, title, text, *a, **k: (
                hit.__setitem__("info", True),
                hit.__setitem__("text", str(text)),
                0,
            )[-1]
        ),
        raising=False,
    )
    monkeypatch.setattr(
        mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False
    )

    # Stub the *export* itself to be instant and non-blocking
    called = {"path": None}
    monkeypatch.setattr(
        w.db, "export_sqlcipher", lambda p: called.__setitem__("path", p), raising=False
    )

    w._backup()

    # Assertions: suffix added, export invoked, success toast shown
    assert called["path"] == str(dest.with_suffix(".db"))
    assert hit["info"]
    assert str(dest.with_suffix(".db")) in hit["text"]


def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    from bouquin.settings import get_settings

    s = get_settings()
    s.setValue("db/default_db", str(fresh_db.cfg.path))
    s.setValue("db/key", fresh_db.cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Create exactly one extra tab (there is already one from __init__)
    d1 = QDate(2020, 1, 1)
    w._open_date_in_tab(d1)
    assert w.tab_widget.count() == 2

    # Close one tab: should call _save_editor_content on its editor
    saved = {"called": False}

    def fake_save_editor(editor):
        saved["called"] = True

    monkeypatch.setattr(w, "_save_editor_content", fake_save_editor, raising=True)
    w._close_tab(0)
    assert saved["called"]
    # Now only one tab remains; closing should no-op
    count_before = w.tab_widget.count()
    w._close_tab(0)
    assert w.tab_widget.count() == count_before
    monkeypatch.delattr(w, "_save_editor_content", raising=False)


def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Case A: no geometry stored -> should call _move_to_cursor_screen_center
    moved = {"hit": False}
    monkeypatch.setattr(
        w,
        "_move_to_cursor_screen_center",
        lambda: moved.__setitem__("hit", True),
        raising=True,
    )
    # clear any stored geometry
    w.settings.remove("main/geometry")
    w.settings.remove("main/windowState")
    w.settings.remove("main/maximized")
    w._restore_window_position()
    assert moved["hit"]

    # Case B: geometry present but off-screen -> fallback to move_to_cursor
    moved["hit"] = False
    # Save a valid geometry then lie that it's offscreen
    geom = w.saveGeometry()
    w.settings.setValue("main/geometry", geom)
    w.settings.setValue("main/windowState", w.saveState())
    w.settings.setValue("main/maximized", False)
    monkeypatch.setattr(w, "_rect_on_any_screen", lambda r: False, raising=True)
    w._restore_window_position()
    assert moved["hit"]

    # Case C: was_max True triggers showMaximized via QTimer.singleShot
    called = {"max": False}
    monkeypatch.setattr(
        w, "showMaximized", lambda: called.__setitem__("max", True), raising=True
    )
    monkeypatch.setattr(
        mwmod.QTimer, "singleShot", staticmethod(lambda _ms, f: f()), raising=False
    )
    w.settings.setValue("main/maximized", True)
    w._restore_window_position()
    assert called["max"]


def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch):
    # Seed DB so refresh marks does something
    fresh_db.save_new_version("2021-08-15", "note", "")
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Show a month with leading days
    qd = QDate(2021, 8, 15)
    w.calendar.setSelectedDate(qd)

    # Grab the internal table view and pick a couple of indices
    view = w.calendar.findChild(QTableView, "qt_calendar_calendarview")
    model = view.model()

    # Find the first index belonging to current month (day == 1)
    first_idx = None
    for r in range(model.rowCount()):
        for c in range(model.columnCount()):
            if model.index(r, c).data() == 1:
                first_idx = model.index(r, c)
                break
        if first_idx:
            break

    assert first_idx is not None

    # A cell before 'first_idx' should map to previous month
    col0 = 0 if first_idx.column() > 0 else 1
    idx_prev = model.index(first_idx.row(), col0)
    vp_pos = view.visualRect(idx_prev).center()
    global_pos = view.viewport().mapToGlobal(vp_pos)
    cal_pos = w.calendar.mapFromGlobal(global_pos)
    date_prev = w._date_from_calendar_pos(cal_pos)
    assert isinstance(date_prev, QDate) and date_prev.isValid()

    # A cell after the last day should map to next month
    last_day = QDate(qd.year(), qd.month(), 1).addMonths(1).addDays(-1).day()
    last_idx = None
    for r in range(model.rowCount() - 1, -1, -1):
        for c in range(model.columnCount() - 1, -1, -1):
            if model.index(r, c).data() == last_day:
                last_idx = model.index(r, c)
                break
        if last_idx:
            break
    assert last_idx is not None

    c_next = min(model.columnCount() - 1, last_idx.column() + 1)
    idx_next = model.index(last_idx.row(), c_next)
    vp_pos2 = view.visualRect(idx_next).center()
    global_pos2 = view.viewport().mapToGlobal(vp_pos2)
    cal_pos2 = w.calendar.mapFromGlobal(global_pos2)
    date_next = w._date_from_calendar_pos(cal_pos2)
    assert isinstance(date_next, QDate) and date_next.isValid()

    # Context menu path: return the "Open in New Tab" action
    class DummyMenu:
        def __init__(self, parent=None):
            self._action = object()

        def addAction(self, text):
            return self._action

        def exec_(self, *args, **kwargs):
            return self._action

    monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True)
    w._show_calendar_context_menu(cal_pos)


def test_event_filter_keypress_starts_idle_timer(qtbot, app):
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()
    ev = QEvent(QEvent.KeyPress)
    w.eventFilter(w, ev)


def _make_main_window(tmp_db_cfg, app, monkeypatch, fresh_db=None):
    """Create a MainWindow wired to an existing db config so it doesn't prompt for key."""
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    # Force MainWindow to pick up our provided cfg
    monkeypatch.setattr(mwmod, "load_db_config", lambda: tmp_db_cfg, raising=True)
    # Make sure DB connects fine
    if fresh_db is None:
        fresh_db = DBManager(tmp_db_cfg)
        assert fresh_db.connect()
    w = MainWindow(themes)
    return w


def test_init_exits_when_prompt_rejected(app, monkeypatch, tmp_path):
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))

    # cfg with empty key and non-existent path => first_time True
    cfg = DBConfig(
        path=tmp_path / "does_not_exist.db",
        key="",
        idle_minutes=0,
        theme="light",
        move_todos=True,
    )
    monkeypatch.setattr(mwmod, "load_db_config", lambda: cfg, raising=True)

    # Avoid accidentaly creating DB by short-circuiting the prompt loop
    class MW(MainWindow):
        def _prompt_for_key_until_valid(self, first_time: bool) -> bool:  # noqa: N802
            assert first_time is True
            return False

    with pytest.raises(SystemExit):
        MW(themes)


@pytest.mark.parametrize(
    "msg, expect_key_msg",
    [
        ("file is not a database", True),
        ("totally unrelated", False),
    ],
)
def test_try_connect_maps_errors(
    qtbot, tmp_db_cfg, app, monkeypatch, msg, expect_key_msg
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # Make connect() raise
    def boom(self):
        raise Exception(msg)

    monkeypatch.setattr(mwmod.DBManager, "connect", boom, raising=True)

    shown = {}

    def fake_critical(parent, title, text, *a, **k):
        shown["title"] = title
        shown["text"] = str(text)
        return 0

    monkeypatch.setattr(
        mwmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
    )

    w._try_connect()

    # And we still showed the right error message
    assert "database" in shown["title"].lower()
    if expect_key_msg:
        assert "key" in shown["text"].lower()
    else:
        assert "unrelated" in shown["text"].lower()
    # If closeEvent later explodes here, that is an app bug (should guard partial init).


# ---- _prompt_for_key_until_valid (324-332) ----


def test_prompt_for_key_cancel_returns_false(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    class CancelPrompt:
        def __init__(self, *a, **k):
            pass

        def exec(self):
            return mwmod.QDialog.Rejected

        def key(self):
            return ""

        def db_path(self) -> Path | None:
            return "foo.db"

    monkeypatch.setattr(mwmod, "KeyPrompt", CancelPrompt, raising=True)
    assert w._prompt_for_key_until_valid(first_time=False) is False


def test_prompt_for_key_accept_then_connects(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    class OKPrompt:
        def __init__(self, *a, **k):
            pass

        def exec(self):
            return mwmod.QDialog.Accepted

        def key(self):
            return "abc"

        def db_path(self) -> Path | None:
            return "foo.db"

    monkeypatch.setattr(mwmod, "KeyPrompt", OKPrompt, raising=True)
    monkeypatch.setattr(w, "_try_connect", lambda: True, raising=True)
    assert w._prompt_for_key_until_valid(first_time=True) is True
    assert w.cfg.key == "abc"


# ---- Tabs/date management (368, 383, 388-391, 427-428, …) ----


def test_reorder_tabs_by_date_and_tab_lookup(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    d1 = QDate.fromString("2024-01-10", "yyyy-MM-dd")
    d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd")
    d3 = QDate.fromString("2024-02-01", "yyyy-MM-dd")

    e2 = w._create_new_tab(d2)
    e3 = w._create_new_tab(d3)
    e1 = w._create_new_tab(d1)  # out of order on purpose

    # Force a reorder and verify ascending order for the three we added.
    w._reorder_tabs_by_date()
    order = [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())]
    today = QDate.currentDate().toString("yyyy-MM-dd")
    order_wo_today = [d for d in order if d != today]
    assert order_wo_today[:3] == ["2024-01-01", "2024-01-10", "2024-02-01"]

    # _tab_index_for_date finds existing and returns -1 for absent
    assert w._tab_index_for_date(d2) != -1
    assert w._tab_index_for_date(QDate.fromString("1999-12-31", "yyyy-MM-dd")) == -1
    assert e1 is not None and e2 is not None and e3 is not None


def test_open_close_tabs_and_focus(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    today = QDate.currentDate()
    ed = w._open_date_in_tab(today)  # creates new if needed
    assert hasattr(ed, "current_date")
    # make another tab to allow closing
    w._create_new_tab(today.addDays(1))
    count = w.tab_widget.count()
    w._close_tab(0)
    assert w.tab_widget.count() == count - 1


# ---- _set_editor_markdown_preserve_view & load with extra_data (631) ----


def test_load_selected_date_appends_extra_data_with_newline(
    qtbot, tmp_db_cfg, app, monkeypatch, fresh_db
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch, fresh_db)
    qtbot.addWidget(w)
    w.db.save_new_version("2020-01-01", "first line", "seed")
    w._load_selected_date("2020-01-01", extra_data="- [ ] carry")
    assert "carry" in w.editor.toPlainText()


# ---- _save_editor_content early return (679) ----


def test_save_editor_content_returns_without_current_date(
    qtbot, tmp_db_cfg, app, monkeypatch
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    from PySide6.QtWidgets import QTextEdit

    other = QTextEdit()
    w._save_editor_content(other)  # should early-return, not crash


# ---- _adjust_today creates a tab (695-696) ----


def test_adjust_today_creates_tab(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)
    w._adjust_today()
    today = QDate.currentDate().toString("yyyy-MM-dd")
    assert any(w.tab_widget.tabText(i) == today for i in range(w.tab_widget.count()))


# ---- _on_date_changed guard for context menus (745) ----


def test_on_date_changed_early_return_when_context_menu(
    qtbot, tmp_db_cfg, app, monkeypatch
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)
    w._showing_context_menu = True
    # Should not throw or load
    w._on_date_changed()
    assert w._showing_context_menu is True  # not toggled here


# ---- _save_current explicit paths (793-801, 809-810) ----


def test_save_current_explicit_cancel(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    class CancelSave:
        def __init__(self, *a, **k):
            pass

        def exec(self):
            return mwmod.QDialog.Rejected

        def note_text(self):
            return ""

    monkeypatch.setattr(mwmod, "SaveDialog", CancelSave, raising=True)
    # returns early, does not raise
    w._save_current(explicit=True)


def test_save_current_explicit_accept(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    class OKSave:
        def __init__(self, *a, **k):
            pass

        def exec(self):
            return mwmod.QDialog.Accepted

        def note_text(self):
            return "manual"

    monkeypatch.setattr(mwmod, "SaveDialog", OKSave, raising=True)
    w._save_current(explicit=True)  # should start/restart timers without error


# ---- Search highlighting (852-854) ----


def test_apply_search_highlights_adds_and_clears(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)
    d1 = QDate.currentDate()
    d2 = d1.addDays(1)
    w._apply_search_highlights({d1, d2})
    # now drop d2 so clear-path runs
    w._apply_search_highlights({d1})
    fmt = w.calendar.dateTextFormat(d1)
    assert fmt.background().style() != Qt.NoBrush


# ---- History dialog glue (956, 961-962) ----


def test_open_history_accept_refreshes(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    called = {"load": False, "refresh": False}

    def fake_load(date_iso=None):
        called["load"] = True

    def fake_refresh():
        called["refresh"] = True

    monkeypatch.setattr(w, "_load_selected_date", fake_load, raising=True)
    monkeypatch.setattr(w, "_refresh_calendar_marks", fake_refresh, raising=True)

    class Dlg:
        def __init__(self, *a, **k):
            pass

        def exec(self):
            return mwmod.QDialog.Accepted

    monkeypatch.setattr(mwmod, "HistoryDialog", Dlg, raising=True)
    w._open_history()
    assert called["load"] and called["refresh"]


# ---- Insert image picker (974, 981-989) ----


def test_on_insert_image_calls_editor_insert(
    qtbot, tmp_db_cfg, app, monkeypatch, tmp_path
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # Make a tiny PNG file
    from PySide6.QtGui import QImage

    img_path = tmp_path / "tiny.png"
    img = QImage(1, 1, QImage.Format_ARGB32)
    img.fill(0xFFFFFFFF)
    img.save(str(img_path))

    called = []

    def fake_picker(*a, **k):
        return ([str(img_path)], "Images (*.png)")

    monkeypatch.setattr(
        mwmod.QFileDialog, "getOpenFileNames", staticmethod(fake_picker), raising=True
    )

    def recorder(path):
        called.append(Path(path))

    monkeypatch.setattr(w.editor, "insert_image_from_path", recorder, raising=True)

    w._on_insert_image()
    assert called and called[0].name == "tiny.png"


# ---- Export flow branches (1062, 1078-1107) ----


@pytest.mark.parametrize(
    "filter_label, method",
    [
        ("JSON (*.json)", "export_json"),
        ("CSV (*.csv)", "export_csv"),
        ("HTML (*.html)", "export_html"),
        ("Markdown (*.md)", "export_markdown"),
        ("SQL (*.sql)", "export_sql"),
    ],
)
def test_export_calls_correct_method(
    qtbot, tmp_db_cfg, app, monkeypatch, tmp_path, filter_label, method
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # Confirm 'Yes' using the real QMessageBox; patch methods only
    monkeypatch.setattr(
        mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
    )
    monkeypatch.setattr(
        mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
    )
    monkeypatch.setattr(
        mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False
    )

    # Return a path without extension to exercise default-ext logic too
    out = tmp_path / "out"
    monkeypatch.setattr(
        mwmod.QFileDialog,
        "getSaveFileName",
        staticmethod(lambda *a, **k: (str(out), filter_label)),
        raising=True,
    )

    # Provide some entries
    monkeypatch.setattr(
        w.db, "get_all_entries", lambda: [("2024-01-01", "x")], raising=True
    )

    called = {"name": None}

    def mark(*a, **k):
        called["name"] = method

    monkeypatch.setattr(w.db, method, mark, raising=True)
    w._export()
    assert called["name"] == method


def test_export_unknown_filter_raises_and_is_caught(
    qtbot, tmp_db_cfg, app, monkeypatch, tmp_path
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # Pre-export confirmation: Yes
    monkeypatch.setattr(
        mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
    )

    out = tmp_path / "weird"
    monkeypatch.setattr(
        mwmod.QFileDialog,
        "getSaveFileName",
        staticmethod(lambda *a, **k: (str(out), "WUT (*.wut)")),
        raising=True,
    )

    # Capture the critical call
    seen = {}

    def critical(parent, title, text, *a, **k):
        seen["title"] = title
        seen["text"] = str(text)
        return 0

    monkeypatch.setattr(
        mwmod.QMessageBox, "critical", staticmethod(critical), raising=False
    )

    # And stub entries
    monkeypatch.setattr(w.db, "get_all_entries", lambda: [], raising=True)

    w._export()
    assert "export" in seen["title"].lower()


# ---- Backup handler (1147-1148) ----


def test_backup_success_and_error(qtbot, tmp_db_cfg, app, monkeypatch, tmp_path):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # Always choose a filename and the SQL option
    out = tmp_path / "backup"
    monkeypatch.setattr(
        mwmod.QFileDialog,
        "getSaveFileName",
        staticmethod(lambda *a, **k: (str(out), "SQLCipher (*.db)")),
        raising=True,
    )

    called = {"ok": False, "err": False}

    def ok_export(path):
        called["ok"] = True

    def crit(parent, title, text, *a, **k):
        called["err"] = True
        return 0

    # First success
    monkeypatch.setattr(w.db, "export_sqlcipher", ok_export, raising=True)
    monkeypatch.setattr(
        mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
    )
    monkeypatch.setattr(
        mwmod.QMessageBox, "critical", staticmethod(crit), raising=False
    )
    w._backup()
    assert called["ok"]

    # Then failure
    def boom(path):
        raise RuntimeError("nope")

    monkeypatch.setattr(w.db, "export_sqlcipher", boom, raising=True)
    w._backup()
    assert called["err"]


# ---- Help openers (1152-1169) ----


def test_open_docs_show_warning_on_failure(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    monkeypatch.setattr(
        mwmod.QDesktopServices, "openUrl", staticmethod(lambda *a: False), raising=True
    )
    seen = {"docs": False, "bugs": False}

    def warn(parent, title, text, *a, **k):
        if "documentation" in title.lower():
            seen["docs"] = True
        return 0

    monkeypatch.setattr(
        mwmod, "QMessageBox", type("MB", (), {"warning": staticmethod(warn)})
    )
    w._open_docs()

    assert seen["docs"]


def test_open_version(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    called = {"title": None, "text": None, "check_called": False}

    # Fake QMessageBox that mimics the bits VersionChecker.show_version_dialog uses
    class FakeMessageBox:
        # provide the enum attributes the code references
        Information = 0
        ActionRole = 1
        Close = 2

        def __init__(self, parent=None):
            self._parent = parent
            self._icon = None
            self._title = ""
            self._text = ""
            self._buttons = []
            self._clicked = None

        def setIcon(self, icon):
            self._icon = icon

        def setIconPixmap(self, icon):
            self._icon = icon

        def setWindowTitle(self, title):
            self._title = title
            called["title"] = title

        def setText(self, text):
            self._text = text
            called["text"] = text

        def addButton(self, *args, **kwargs):
            # We don't care about the label/role, we just need a distinct object
            btn = object()
            self._buttons.append(btn)
            return btn

        def exec(self):
            # Simulate user clicking the *Close* button, i.e. the second button
            if self._buttons:
                # show_version_dialog adds buttons in order:
                #   0 -> "Check for updates"
                #   1 -> Close
                self._clicked = self._buttons[-1]

        def clickedButton(self):
            return self._clicked

    # Patch the QMessageBox used *inside* version_check.py
    monkeypatch.setattr(version_check, "QMessageBox", FakeMessageBox)

    # Optional: track if check_for_updates would be called
    def fake_check_for_updates(self):
        called["check_called"] = True

    monkeypatch.setattr(
        version_check.VersionChecker, "check_for_updates", fake_check_for_updates
    )

    # Call the entrypoint
    w._open_version()

    # Assertions: title and text got set correctly
    assert called["title"] is not None
    assert "version" in called["title"].lower()

    version = importlib.metadata.version("bouquin")
    assert version in called["text"]

    # And we simulated closing, so "Check for updates" should not have fired
    assert called["check_called"] is False


# ---- Idle/lock/event filter helpers (1176, 1181-1187, 1193-1202, 1231-1233) ----


def test_apply_idle_minutes_paths_and_unlock(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # remove timer to hit early return
    delattr(w, "_idle_timer")
    w._apply_idle_minutes(5)  # no crash

    # re-create a timer and simulate locking then disabling idle
    w._idle_timer = QTimer(w)
    w._idle_timer.setSingleShot(True)
    w._locked = True
    w._apply_idle_minutes(0)
    assert not w._locked  # unlocked and overlay hidden path covered


def test_event_filter_sets_context_flag_and_keystart(
    qtbot, tmp_db_cfg, app, monkeypatch
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)
    # Right-click on calendar with a real QMouseEvent
    me = QMouseEvent(
        QEvent.MouseButtonPress,
        QPoint(0, 0),
        Qt.RightButton,
        Qt.RightButton,
        Qt.NoModifier,
    )
    w.eventFilter(w.calendar, me)
    assert getattr(w, "_showing_context_menu", False) is True

    # KeyPress restarts idle timer when unlocked (real QKeyEvent)
    w._locked = False
    ke = QKeyEvent(QEvent.KeyPress, Qt.Key_A, Qt.NoModifier)
    w.eventFilter(w, ke)


def test_unlock_exception_path(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    def boom(*a, **k):
        raise RuntimeError("nope")

    monkeypatch.setattr(w, "_prompt_for_key_until_valid", boom, raising=True)

    captured = {}

    def critical(parent, title, text, *a, **k):
        captured["title"] = title
        return 0

    monkeypatch.setattr(
        mwmod, "QMessageBox", type("MB", (), {"critical": staticmethod(critical)})
    )
    w._on_unlock_clicked()
    assert "unlock" in captured["title"].lower()


# ---- Focus helpers (1273-1275, 1290) ----


def test_focus_editor_now_and_app_state(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)
    w.show()
    # Locked => early return
    w._locked = True
    w._focus_editor_now()
    # Active window path (force isActiveWindow)
    w._locked = False
    monkeypatch.setattr(w, "isActiveWindow", lambda: True, raising=True)
    w._focus_editor_now()
    # App state callback path
    w._on_app_state_changed(Qt.ApplicationActive)


# ---- _rect_on_any_screen false path (1039) ----


def test_rect_on_any_screen_false(qtbot, tmp_db_cfg, app, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)
    sc = QApplication.primaryScreen().availableGeometry()
    far = QRect(sc.right() + 10_000, sc.bottom() + 10_000, 100, 100)
    assert not w._rect_on_any_screen(far)


def test_reorder_tabs_with_undated_page_stays_last(qtbot, app, tmp_db_cfg, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # Create two dated tabs (out of order), plus an undated QWidget page.
    d1 = QDate.fromString("2024-02-01", "yyyy-MM-dd")
    d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd")
    w._create_new_tab(d1)
    w._create_new_tab(d2)

    misc = QWidget()
    w.tab_widget.addTab(misc, "misc")

    # Reorder and check: dated tabs sorted first, undated kept after them
    w._reorder_tabs_by_date()
    labels = [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())]
    assert labels[0] <= labels[1]
    assert labels[-1] == "misc"

    # Also cover lookup for an existing date
    assert w._tab_index_for_date(d2) != -1


def test_index_for_date_insert_positions(qtbot, app, tmp_db_cfg, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    a = QDate.fromString("2024-01-01", "yyyy-MM-dd")
    c = QDate.fromString("2024-03-01", "yyyy-MM-dd")
    d = QDate.fromString("2024-04-01", "yyyy-MM-dd")

    def expected():
        key = (d.year(), d.month(), d.day())
        for i in range(w.tab_widget.count()):
            ed = w.tab_widget.widget(i)
            cur = getattr(ed, "current_date", None)
            if isinstance(cur, QDate) and cur.isValid():
                if (cur.year(), cur.month(), cur.day()) > key:
                    return i
        return w.tab_widget.count()

    w._create_new_tab(a)
    w._create_new_tab(c)

    # B belongs between A and C
    b = QDate.fromString("2024-02-01", "yyyy-MM-dd")
    assert w._index_for_date_insert(b) == 1

    # Date prior to first should insert at 0
    z = QDate.fromString("2023-12-31", "yyyy-MM-dd")
    assert w._index_for_date_insert(z) == 0

    # Date after last should append
    d = QDate.fromString("2024-04-01", "yyyy-MM-dd")
    assert w._index_for_date_insert(d) == expected()


def test_on_tab_changed_early_and_stop_guard(qtbot, app, tmp_db_cfg, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # Early return path
    w._on_tab_changed(-1)

    # Create two tabs then make stop() raise to hit try/except guard
    w._create_new_tab(QDate.fromString("2024-01-01", "yyyy-MM-dd"))
    w._create_new_tab(QDate.fromString("2024-01-02", "yyyy-MM-dd"))

    def boom():
        raise RuntimeError("stop failed")

    monkeypatch.setattr(w._save_timer, "stop", boom, raising=True)
    w._on_tab_changed(1)  # should not raise


def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatch):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)
    before = w.tab_widget.count()

    class DummyMenu:
        def __init__(self, *a, **k):
            pass

        def addAction(self, *a, **k):
            return object()

        def exec_(self, *a, **k):
            return None  # return no action

    monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True)
    w._show_calendar_context_menu(w.calendar.rect().center())  # nothing should happen
    assert w.tab_widget.count() == before


def test_export_cancel_then_empty_filename(
    qtbot, app, tmp_db_cfg, monkeypatch, tmp_path
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # 1) cancel at the confirmation dialog
    class FullMB:
        Yes = QMessageBox.Yes
        No = QMessageBox.No
        Warning = QMessageBox.Warning

        def __init__(self, *a, **k):
            pass

        def setWindowTitle(self, *a):
            pass

        def setText(self, *a):
            pass

        def setStandardButtons(self, *a):
            pass

        def setIcon(self, *a):
            pass

        def show(self):
            pass

        def adjustSize(self):
            pass

        def exec(self):
            return self.No

        @staticmethod
        def information(*a, **k):
            return 0

        @staticmethod
        def critical(*a, **k):
            return 0

    monkeypatch.setattr(mwmod, "QMessageBox", FullMB, raising=True)
    w._export()  # returns early on No

    # 2) Yes in confirmation, but user cancels file dialog (empty filename)
    class YesOnly(FullMB):
        def exec(self):
            return self.Yes

    monkeypatch.setattr(mwmod, "QMessageBox", YesOnly, raising=True)
    monkeypatch.setattr(
        mwmod.QFileDialog,
        "getSaveFileName",
        staticmethod(lambda *a, **k: ("", "Markdown (*.md)")),
        raising=False,
    )
    w._export()  # returns early at filename check


def test_set_editor_markdown_preserve_view_preserves(
    qtbot, app, tmp_db_cfg, monkeypatch
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    long = "line\n" * 200
    w.editor.from_markdown(long)
    w.editor.verticalScrollBar().setValue(50)
    w.editor.moveCursor(QTextCursor.End)
    pos_before = w.editor.textCursor().position()
    v_before = w.editor.verticalScrollBar().value()

    # Same markdown → no rewrite, caret/scroll restored
    w._set_editor_markdown_preserve_view(long)
    assert w.editor.textCursor().position() == pos_before
    assert w.editor.verticalScrollBar().value() == v_before

    # Different markdown → rewritten but caret restoration still executes
    changed = long + "extra\n"
    w._set_editor_markdown_preserve_view(changed)
    assert w.editor.to_markdown().endswith("extra\n")


def test_load_date_into_editor_with_extra_data_forces_save(
    qtbot, app, tmp_db_cfg, monkeypatch
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    called = {"iso": None, "explicit": None}

    def save_date(iso, explicit):
        called.update(iso=iso, explicit=explicit)

    monkeypatch.setattr(w, "_save_date", save_date, raising=True)
    d = QDate.fromString("2020-01-01", "yyyy-MM-dd")
    w._load_date_into_editor(d, extra_data="- [ ] carry")
    assert called["iso"] == "2020-01-01" and called["explicit"] is True


def test_reorder_tabs_moves_and_undated(qtbot, app, tmp_db_cfg, monkeypatch):
    """Covers moveTab for both dated and undated buckets."""
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # Create out-of-order dated tabs
    d1 = QDate.fromString("2024-01-10", "yyyy-MM-dd")
    d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd")
    d3 = QDate.fromString("2024-02-01", "yyyy-MM-dd")
    w._create_new_tab(d1)
    w._create_new_tab(d3)
    w._create_new_tab(d2)

    # Also insert an "undated" plain QWidget at the front to force a move later
    undated = QWidget()
    w.tab_widget.insertTab(0, undated, "undated")

    moved = {"calls": []}
    tb = w.tab_widget.tabBar()

    def spy_moveTab(frm, to):
        moved["calls"].append((frm, to))
        # don't actually move; we just need the call for coverage

    monkeypatch.setattr(tb, "moveTab", spy_moveTab, raising=True)

    w._reorder_tabs_by_date()
    assert moved["calls"]  # both dated and undated moves should have occurred


def test_date_from_calendar_view_none(qtbot, app, tmp_db_cfg, monkeypatch):
    """Covers early return when calendar view can't be found."""
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    monkeypatch.setattr(
        w.calendar,
        "findChild",
        lambda *a, **k: None,  # pretend the internal view is missing
        raising=False,
    )
    assert w._date_from_calendar_pos(QPoint(1, 1)) is None


def test_date_from_calendar_no_first_or_last(qtbot, app, tmp_db_cfg, monkeypatch):
    """
    Covers early returns when first_index or last_index cannot be found
    """
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    class FakeModel:
        def rowCount(self):
            return 1

        def columnCount(self):
            return 1

        class Idx:
            def data(self):
                return None  # never equals first/last-day

        def index(self, *_):
            return self.Idx()

    class FakeView:
        def model(self):
            return FakeModel()

        class VP:
            def mapToGlobal(self, p):
                return p

            def mapFrom(self, *_):  # calendar-local -> viewport
                return QPoint(0, 0)

        def viewport(self):
            return self.VP()

        def indexAt(self, *_):
            # return an *instance* whose isValid() returns False
            return type("I", (), {"isValid": lambda self: False})()

    monkeypatch.setattr(
        w.calendar,
        "findChild",
        lambda *a, **k: FakeView(),
        raising=False,
    )
    # Early return when first day (1) can't be found
    assert w._date_from_calendar_pos(QPoint(5, 5)) is None


def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypatch):
    """Covers DB not connected branch."""
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # window editor has a current_date; simulate a dropped connection
    assert isinstance(w.db, DBManager)
    w.db.conn = None
    # no crash -> branch hit
    w._save_editor_content(w.editor)


def test_on_date_changed_stops_timer_and_saves_prev_when_dirty(
    qtbot, app, tmp_db_cfg, monkeypatch
):
    """
    Covers the exception-protected _save_timer.stop() and
    the prev-date save path.
    """
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # Make timer.stop() raise so the except path is covered
    w._dirty = True
    w.editor.current_date = QDate.fromString("2024-01-01", "yyyy-MM-dd")
    # make timer.stop() raise so the except path is exercised
    monkeypatch.setattr(
        w._save_timer,
        "stop",
        lambda: (_ for _ in ()).throw(RuntimeError("boom")),
        raising=True,
    )

    saved = {"iso": None}

    def stub_save_date(iso, explicit=False, note=None):
        saved["iso"] = iso

    monkeypatch.setattr(w, "_save_date", stub_save_date, raising=False)

    w._on_date_changed()
    assert saved["iso"] == "2024-01-01"


def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch):
    """Covers early return when toolbar is already bound."""
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)
    w._bind_toolbar()
    # Call again; line is covered if this no-ops
    w._bind_toolbar()


def test_on_insert_image_no_selection_returns(qtbot, app, tmp_db_cfg, monkeypatch):
    """Covers the early return when user selects no files."""
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    monkeypatch.setattr(
        mwmod.QFileDialog,
        "getOpenFileNames",
        staticmethod(lambda *a, **k: ([], "")),
        raising=True,
    )

    # Ensure we would notice if it tried to insert anything
    monkeypatch.setattr(
        w.editor,
        "insert_image_from_path",
        lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not insert")),
        raising=True,
    )
    w._on_insert_image()


def test_apply_idle_minutes_starts_when_unlocked(qtbot, app, tmp_db_cfg, monkeypatch):
    """Covers set + start when minutes>0 and not locked."""
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)
    w._locked = False

    hit = {"start": False}
    monkeypatch.setattr(
        w._idle_timer,
        "start",
        lambda *a, **k: hit.__setitem__("start", True),
        raising=True,
    )
    w._apply_idle_minutes(7)
    assert hit["start"]


def test_close_event_handles_settings_failures(qtbot, app, tmp_db_cfg, monkeypatch):
    """
    Covers exception swallowing around settings writes & ensures close proceeds
    """
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    qtbot.addWidget(w)

    # A couple of tabs so _save_editor_content runs during close
    w._create_new_tab(QDate.fromString("2024-01-01", "yyyy-MM-dd"))

    # Make settings.setValue raise for coverage
    orig_set = w.settings.setValue

    def flaky_set(*a, **k):
        if a[0] in ("main/geometry", "main/windowState"):
            raise RuntimeError("boom")
        return orig_set(*a, **k)

    monkeypatch.setattr(w.settings, "setValue", flaky_set, raising=True)

    # Should not crash
    w.close()


def test_on_date_changed_ignored_when_context_menu_shown(
    qtbot, app, tmp_db_cfg, monkeypatch
):
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)
    # Simulate flag set by context menu to ensure early return path
    w._showing_context_menu = True
    w._on_date_changed()  # should simply return without raising
    assert True  # reached


def test_closeEvent_swallows_exceptions(qtbot, app, tmp_db_cfg, monkeypatch):
    called = {"save": False, "close": False}
    w = _make_main_window(tmp_db_cfg, app, monkeypatch)

    def bad_save(editor):
        called["save"] = True
        raise RuntimeError("boom")

    class DummyDB:
        def __init__(self):
            self.conn = object()  # <-- make conn truthy so branch is taken

        def close(self):
            called["close"] = True
            raise RuntimeError("kaboom")

    class DummyTabs:
        def count(self):
            return 1  # <-- ensures the save loop runs

        def widget(self, i):
            return object()  # any non-None editor-like object

    # Patch the pieces the closeEvent checks
    w.tab_widget = DummyTabs()
    w.db = DummyDB()
    monkeypatch.setattr(w, "_save_editor_content", bad_save, raising=True)

    # Fire the event
    ev = QCloseEvent()
    w.closeEvent(ev)

    assert called["save"] and called["close"]


# ============================================================================
# Tag Save Handler Tests
# ============================================================================


def test_main_window_do_tag_save_with_editor(app, fresh_db, tmp_db_cfg, monkeypatch):
    """Test _do_tag_save when editor has current_date"""
    # Skip the key prompt
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    # Set a date on the editor
    date = QDate(2024, 1, 15)
    window.editor.current_date = date
    window.editor.from_markdown("Test content")

    # Call _do_tag_save
    window._do_tag_save()

    # Should have saved
    fresh_db.get_entry("2024-01-15")
    # May or may not have content depending on timing, but should not crash
    assert True


def test_main_window_do_tag_save_no_editor(app, fresh_db, tmp_db_cfg, monkeypatch):
    """Test _do_tag_save when editor doesn't have current_date"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    # Remove current_date attribute
    if hasattr(window.editor, "current_date"):
        delattr(window.editor, "current_date")

    # Call _do_tag_save - should handle gracefully
    window._do_tag_save()

    assert True


def test_main_window_on_tag_added_triggers_deferred_save(
    app, fresh_db, tmp_db_cfg, monkeypatch
):
    """Test that _on_tag_added defers the save"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    # Mock QTimer.singleShot
    with patch("PySide6.QtCore.QTimer.singleShot") as mock_timer:
        window._on_tag_added()

        # Should have called singleShot
        mock_timer.assert_called_once()
        args = mock_timer.call_args[0]
        assert args[0] == 0  # Delay of 0
        assert callable(args[1])  # Callback function


# ============================================================================
# Tag Activation Tests
# ============================================================================


def test_main_window_on_tag_activated_with_date(app, fresh_db, tmp_db_cfg, monkeypatch):
    """Test _on_tag_activated when passed a date string"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    # Mock _load_selected_date
    window._load_selected_date = Mock()

    # Call with date format
    window._on_tag_activated("2024-01-15")

    # Should have called _load_selected_date
    window._load_selected_date.assert_called_once_with("2024-01-15")


def test_main_window_on_tag_activated_with_tag_name(
    app, fresh_db, tmp_db_cfg, monkeypatch
):
    """Test _on_tag_activated when passed a tag name"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    # Mock the tag browser dialog (it's imported locally in the method)
    with patch("bouquin.tag_browser.TagBrowserDialog") as mock_dialog:
        mock_instance = Mock()
        mock_instance.openDateRequested = Mock()
        mock_instance.exec.return_value = QDialog.Accepted
        mock_dialog.return_value = mock_instance

        # Call with tag name
        window._on_tag_activated("worktag")

        # Should have opened dialog
        mock_dialog.assert_called_once()
        # Check focus_tag was passed
        call_kwargs = mock_dialog.call_args[1]
        assert call_kwargs.get("focus_tag") == "worktag"


# ============================================================================
# Settings Path Change Tests
# ============================================================================


def test_main_window_settings_path_change_success(
    app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch
):
    """Test changing database path in settings"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    new_path = tmp_path / "new.db"

    # Mock the settings dialog
    with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
        mock_instance = Mock()
        mock_instance.exec.return_value = QDialog.Accepted

        # Create a new config with different path
        new_cfg = Mock()
        new_cfg.path = str(new_path)
        new_cfg.key = tmp_db_cfg.key
        new_cfg.idle_minutes = 15
        new_cfg.theme = "light"
        new_cfg.move_todos = True
        new_cfg.locale = "en"
        new_cfg.font_size = 11

        mock_instance.config = new_cfg
        mock_dialog.return_value = mock_instance

        # Mock _prompt_for_key_until_valid to return True
        window._prompt_for_key_until_valid = Mock(return_value=True)
        # Also mock _load_selected_date and _refresh_calendar_marks since we don't have a real DB connection
        window._load_selected_date = Mock()
        window._refresh_calendar_marks = Mock()

        # Open settings
        window._open_settings()

        # Path should have changed
        assert window.cfg.path == str(new_path)


def test_main_window_settings_path_change_failure(
    app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch
):
    """Test failed database path change shows warning"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    new_path = tmp_path / "new.db"

    # Mock the settings dialog
    with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
        mock_instance = Mock()
        mock_instance.exec.return_value = QDialog.Accepted

        new_cfg = Mock()
        new_cfg.path = str(new_path)
        new_cfg.key = tmp_db_cfg.key
        new_cfg.idle_minutes = 15
        new_cfg.theme = "light"
        new_cfg.move_todos = True
        new_cfg.locale = "en"
        new_cfg.font_size = 11

        mock_instance.config = new_cfg
        mock_dialog.return_value = mock_instance

        # Mock _prompt_for_key_until_valid to return False (failure)
        window._prompt_for_key_until_valid = Mock(return_value=False)

        # Mock QMessageBox.warning
        with patch.object(QMessageBox, "warning") as mock_warning:
            # Open settings
            window._open_settings()

            # Warning should have been shown
            mock_warning.assert_called_once()


def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypatch):
    """Test settings change without path change"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    old_path = window.cfg.path

    # Mock the settings dialog
    with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
        mock_instance = Mock()
        mock_instance.exec.return_value = QDialog.Accepted

        # Create config with SAME path
        new_cfg = Mock()
        new_cfg.path = old_path
        new_cfg.key = tmp_db_cfg.key
        new_cfg.idle_minutes = 20  # Changed
        new_cfg.theme = "dark"  # Changed
        new_cfg.move_todos = False  # Changed
        new_cfg.locale = "fr"  # Changed
        new_cfg.font_size = 12  # Changed

        mock_instance.config = new_cfg
        mock_dialog.return_value = mock_instance

        # Open settings
        window._open_settings()

        # Settings should be updated but path didn't change
        assert window.cfg.idle_minutes == 20
        assert window.cfg.theme == "dark"
        assert window.cfg.path == old_path
        assert window.cfg.font_size == 12


def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch):
    """Test cancelling settings dialog"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )
    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    old_theme = window.cfg.theme

    # Mock the settings dialog to be rejected
    with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
        mock_instance = Mock()
        mock_instance.exec.return_value = QDialog.Rejected
        mock_dialog.return_value = mock_instance

        # Open settings
        window._open_settings()

        # Settings should NOT change
        assert window.cfg.theme == old_theme


# ============================================================================
# Update Tag Views Tests
# ============================================================================


def test_main_window_update_tag_views_for_date(app, fresh_db, tmp_db_cfg, monkeypatch):
    """Test _update_tag_views_for_date"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    # Set tags for a date
    fresh_db.set_tags_for_page("2024-01-15", ["test"])

    # Update tag views
    window._update_tag_views_for_date("2024-01-15")

    # Tags widget should have been updated
    assert window.tags._current_date == "2024-01-15"


def test_main_window_update_tag_views_no_tags_widget(
    app, fresh_db, tmp_db_cfg, monkeypatch
):
    """Test _update_tag_views_for_date when tags widget doesn't exist"""
    monkeypatch.setattr(
        "bouquin.main_window.KeyPrompt",
        lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
    )

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes)

    # Remove tags widget
    delattr(window, "tags")

    # Should handle gracefully
    window._update_tag_views_for_date("2024-01-15")

    assert True


def test_main_window_without_tags(qtbot, app, tmp_db_cfg):
    """Test main window when tags feature is disabled."""
    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", False)  # Disabled
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", True)
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Verify tags widget is hidden
    assert window.tags.isHidden()


def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
    """Test main window when time_log feature is disabled."""
    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", False)  # Disabled
    s.setValue("ui/reminders", True)
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Verify time_log widget is hidden
    assert window.time_log.isHidden()
    assert not window.toolBar.actTimer.isVisible()


def test_main_window_without_documents(qtbot, app, tmp_db_cfg):
    """Test main window when documents feature is disabled."""
    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", True)
    s.setValue("ui/documents", False)  # Disabled
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Verify documents widget is hidden
    assert window.todays_documents.isHidden()
    assert not window.toolBar.actDocuments.isVisible()


def test_export_csv_format(qtbot, app, tmp_path, monkeypatch):
    """Test exporting to CSV format - covers export path lines"""
    db_path = tmp_path / "notebook.db"
    s = get_settings()
    s.setValue("db/default_db", str(db_path))
    s.setValue("db/key", "test-key")
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Add some data
    w.db.save_new_version("2024-01-01", "Test content", "test")

    # Mock file dialog to return CSV
    dest = tmp_path / "export_test.csv"
    monkeypatch.setattr(
        mwmod.QFileDialog,
        "getSaveFileName",
        staticmethod(lambda *a, **k: (str(dest), "CSV (*.csv)")),
    )

    # Mock QMessageBox to auto-accept
    monkeypatch.setattr(
        mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
    )
    monkeypatch.setattr(
        mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
    )

    w._export()
    assert dest.exists()


def test_settings_dialog_with_locale_change(qtbot, app, tmp_path, monkeypatch):
    """Test opening settings dialog and changing locale - covers settings dialog paths"""
    db_path = tmp_path / "notebook.db"
    s = get_settings()
    s.setValue("db/default_db", str(db_path))
    s.setValue("db/key", "test-key")
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Mock the settings dialog to auto-accept
    from bouquin.settings_dialog import SettingsDialog

    SettingsDialog.exec

    def fake_exec(self):
        # Change locale before accepting
        idx = self.locale_combobox.findData("fr")
        if idx >= 0:
            self.locale_combobox.setCurrentIndex(idx)
        return mwmod.QDialog.Accepted

    monkeypatch.setattr(SettingsDialog, "exec", fake_exec)

    w._open_settings()
    qtbot.wait(50)


def test_statistics_dialog_open(qtbot, app, tmp_path, monkeypatch):
    """Test opening statistics dialog - covers statistics dialog paths"""
    db_path = tmp_path / "notebook.db"
    s = get_settings()
    s.setValue("db/default_db", str(db_path))
    s.setValue("db/key", "test-key")
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Add some data
    w.db.save_new_version("2024-01-01", "Test content", "test")

    from bouquin.statistics_dialog import StatisticsDialog

    StatisticsDialog.exec

    def fake_exec(self):
        # Just accept immediately
        return mwmod.QDialog.Accepted

    monkeypatch.setattr(StatisticsDialog, "exec", fake_exec)

    w._open_statistics()
    qtbot.wait(50)


def test_bug_report_dialog_open(qtbot, app, tmp_path, monkeypatch):
    """Test opening bug report dialog"""
    db_path = tmp_path / "notebook.db"
    s = get_settings()
    s.setValue("db/default_db", str(db_path))
    s.setValue("db/key", "test-key")
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    from bouquin.bug_report_dialog import BugReportDialog

    BugReportDialog.exec

    def fake_exec(self):
        return mwmod.QDialog.Rejected

    monkeypatch.setattr(BugReportDialog, "exec", fake_exec)

    w._open_bugs()
    qtbot.wait(50)


def test_history_dialog_open_and_restore(qtbot, app, tmp_path, monkeypatch):
    """Test opening history dialog and restoring a version"""
    db_path = tmp_path / "notebook.db"
    s = get_settings()
    s.setValue("db/default_db", str(db_path))
    s.setValue("db/key", "test-key")
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Add some data
    date_str = QDate.currentDate().toString("yyyy-MM-dd")
    w.db.save_new_version(date_str, "Version 1", "v1")
    w.db.save_new_version(date_str, "Version 2", "v2")

    from bouquin.history_dialog import HistoryDialog

    def fake_exec(self):
        # Simulate selecting first version and accepting
        if self.list.count() > 0:
            self.list.setCurrentRow(0)
            self._revert()
        return mwmod.QDialog.Accepted

    monkeypatch.setattr(HistoryDialog, "exec", fake_exec)

    w._open_history()
    qtbot.wait(50)


def test_goto_today_button(qtbot, app, tmp_path):
    """Test going to today's date"""
    db_path = tmp_path / "notebook.db"
    s = get_settings()
    s.setValue("db/default_db", str(db_path))
    s.setValue("db/key", "test-key")
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Move to a different date
    past_date = QDate.currentDate().addDays(-30)
    w.calendar.setSelectedDate(past_date)

    # Go back to today
    w._adjust_today()
    qtbot.wait(50)

    assert w.calendar.selectedDate() == QDate.currentDate()


def test_adjust_font_size(qtbot, app, tmp_path):
    """Test adjusting font size"""
    db_path = tmp_path / "notebook.db"
    s = get_settings()
    s.setValue("db/default_db", str(db_path))
    s.setValue("db/key", "test-key")
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/font_size", 12)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    initial_size = w.editor.font().pointSize()

    # Increase font size
    w._on_font_larger_requested()
    qtbot.wait(50)
    assert w.editor.font().pointSize() > initial_size

    # Decrease font size
    w._on_font_smaller_requested()
    qtbot.wait(50)


def test_calendar_date_selection(qtbot, app, tmp_path):
    """Test selecting a date from calendar"""
    db_path = tmp_path / "notebook.db"
    s = get_settings()
    s.setValue("db/default_db", str(db_path))
    s.setValue("db/key", "test-key")
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    w = MainWindow(themes=themes)
    qtbot.addWidget(w)
    w.show()

    # Select a specific date
    test_date = QDate(2024, 6, 15)
    w.calendar.setSelectedDate(test_date)
    qtbot.wait(50)

    # The window should load that date
    assert test_date.toString("yyyy-MM-dd") in str(w._current_date_iso())


def test_main_window_without_reminders(qtbot, app, tmp_db_cfg):
    """Test main window when reminders feature is disabled."""
    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", False)  # Disabled
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Verify reminders widget is hidden
    assert window.upcoming_reminders.isHidden()
    assert not window.toolBar.actAlarm.isVisible()


def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
    """Test closing the current tab via _close_current_tab."""
    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", True)
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Open multiple tabs
    today = date.today().isoformat()
    tomorrow = (date.today() + timedelta(days=1)).isoformat()

    window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd"))
    window._open_date_in_tab(QDate.fromString(tomorrow, "yyyy-MM-dd"))

    initial_count = window.tab_widget.count()
    assert initial_count >= 2

    # Close current tab
    window._close_current_tab()

    # Verify tab was closed
    assert window.tab_widget.count() == initial_count - 1


def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db):
    """Test parsing inline alarms from markdown (⏰ HH:MM format)."""
    from PySide6.QtCore import QTime

    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", True)
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Open today's date
    today_qdate = QDate.currentDate()
    window._open_date_in_tab(today_qdate)

    # Set content with a future alarm
    future_time = QTime.currentTime().addSecs(3600)  # 1 hour from now
    alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}"

    # Set the editor's current_date attribute
    window.editor.current_date = today_qdate
    window.editor.setPlainText(alarm_text)

    # Clear any existing timers
    window._reminder_timers = []

    # Trigger alarm parsing
    window._rebuild_reminders_for_today()

    # Verify timer was created (not DB reminder)
    assert len(window._reminder_timers) > 0


def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db):
    """Test that invalid time formats are skipped."""
    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", True)
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Open today's date
    today_qdate = QDate.currentDate()
    window._open_date_in_tab(today_qdate)

    # Set content with invalid time
    alarm_text = "Do something ⏰ 25:99"  # Invalid time

    window.editor.current_date = today_qdate
    window.editor.setPlainText(alarm_text)

    # Clear any existing timers
    window._reminder_timers = []

    # Trigger alarm parsing - should not crash
    window._rebuild_reminders_for_today()

    # No timer should be created for invalid time
    assert len(window._reminder_timers) == 0


def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db):
    """Test that past alarms are skipped."""
    from PySide6.QtCore import QTime

    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", True)
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Open today's date
    today_qdate = QDate.currentDate()
    window._open_date_in_tab(today_qdate)

    # Set content with past time
    past_time = QTime.currentTime().addSecs(-3600)  # 1 hour ago
    alarm_text = f"Do something ⏰ {past_time.hour():02d}:{past_time.minute():02d}"

    window.editor.current_date = today_qdate
    window.editor.setPlainText(alarm_text)

    # Clear any existing timers
    window._reminder_timers = []

    # Trigger alarm parsing
    window._rebuild_reminders_for_today()

    # Past alarms should not create timers
    assert len(window._reminder_timers) == 0


def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db):
    """Test alarm with no text before emoji uses fallback."""
    from PySide6.QtCore import QTime

    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", True)
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Open today's date
    today_qdate = QDate.currentDate()
    window._open_date_in_tab(today_qdate)

    # Set content with alarm but no text
    future_time = QTime.currentTime().addSecs(3600)
    alarm_text = f"⏰ {future_time.hour():02d}:{future_time.minute():02d}"

    window.editor.current_date = today_qdate
    window.editor.setPlainText(alarm_text)

    # Clear any existing timers
    window._reminder_timers = []

    # Trigger alarm parsing
    window._rebuild_reminders_for_today()

    # Timer should be created even without text
    assert len(window._reminder_timers) > 0


def test_open_history_with_editor(qtbot, app, tmp_db_cfg, fresh_db):
    """Test opening history when editor has content."""
    from unittest.mock import patch

    s = get_settings()
    s.setValue("db/default_db", str(tmp_db_cfg.path))
    s.setValue("db/key", tmp_db_cfg.key)
    s.setValue("ui/idle_minutes", 0)
    s.setValue("ui/theme", "light")
    s.setValue("ui/move_todos", True)
    s.setValue("ui/tags", True)
    s.setValue("ui/time_log", True)
    s.setValue("ui/reminders", True)
    s.setValue("ui/locale", "en")
    s.setValue("ui/font_size", 11)

    themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
    window = MainWindow(themes=themes)
    qtbot.addWidget(window)
    window.show()

    # Create some history
    today = date.today().isoformat()
    fresh_db.save_new_version(today, "v1", "note1")
    fresh_db.save_new_version(today, "v2", "note2")

    # Open today's date
    window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd"))

    # Open history
    with patch("bouquin.history_dialog.HistoryDialog.exec") as mock_exec:
        mock_exec.return_value = False  # User cancels
        window._open_history()

        # HistoryDialog should have been created and shown
        mock_exec.assert_called_once()
