"""
Regression tests for the post-login settings atomicity invariant.

Three load-bearing properties are locked in here:

1. On failure mid-write, the DB rolls back cleanly — no partial rows,
   no orphaned `app.version`. The next login retries from a clean
   state instead of entering the sticky loop
   (commit 621d8d0d — fix(auth): atomic settings reload + app.version
   update on login).

2. On success, both the defaults import AND `app.version` persist
   together — the invariant that makes
   `db_version_matches_package()` useful.

3. `engine.dispose()` on a busy engine does NOT break a thread holding
   a checked-out connection. This is the SA 2.0 contract
   (`QueuePool.dispose` drains only idle queue entries,
   `Engine.dispose` calls `pool.recreate()`, checked-out connections
   keep working) and we lock it in so a future SA upgrade cannot
   silently change it. This test makes PR #3487's "dispose orphans
   checked-out" mechanism claim falsifiable — if it ever starts being
   true for our SQLCipher+WAL path, this test fails.
"""

import sys
import tempfile
import threading
from pathlib import Path

import pytest
from sqlalchemy.orm import sessionmaker

sys.path.insert(0, str(Path(__file__).parent.parent.parent.resolve()))

from local_deep_research.database.encrypted_db import DatabaseManager
from local_deep_research.database.models import Setting
from local_deep_research.settings.manager import SettingsManager


@pytest.fixture
def temp_data_dir():
    """Per-test temporary directory for encrypted databases."""
    with tempfile.TemporaryDirectory() as tmpdir:
        yield Path(tmpdir)


@pytest.fixture
def db_manager(temp_data_dir, monkeypatch):
    """DatabaseManager pointed at a temp directory.

    Production QueuePool is used (not StaticPool) so the dispose-vs-
    checkout test exercises the real code path.
    """
    monkeypatch.setattr(
        "local_deep_research.database.encrypted_db.get_data_directory",
        lambda: temp_data_dir,
    )
    monkeypatch.delenv("TESTING", raising=False)
    manager = DatabaseManager()
    yield manager
    for username in list(manager.connections.keys()):
        try:
            manager.close_user_database(username)
        except Exception:
            pass


@pytest.fixture
def user_engine(db_manager):
    """Fresh per-test SQLCipher engine for a unique user.

    After `create_user_database`, the engine is already populated by
    `initialize_database` with the full defaults set + `app.version`.
    Tests that need a pre-import state delete the relevant rows in
    their arrange step.
    """
    username = "atomicity_user"
    password = "AtomicityPass123!"
    engine = db_manager.create_user_database(username, password)
    yield username, password, engine


def _has_setting(engine, key: str) -> bool:
    Session = sessionmaker(bind=engine)
    with Session() as s:
        return s.query(Setting).filter(Setting.key == key).first() is not None


def _delete_keys(engine, keys):
    Session = sessionmaker(bind=engine)
    with Session() as s:
        s.query(Setting).filter(Setting.key.in_(list(keys))).delete(
            synchronize_session=False
        )
        s.commit()


def _run_post_login_atomic_block(engine, raise_after_stage: bool = False):
    """Mirror the atomic block in `_perform_post_login_tasks` step 1.

    We call the SettingsManager methods directly against a real engine
    instead of routing through `get_user_db_session` so the test does
    not depend on Flask session state. The transaction scope is
    identical: one session, one terminal commit.

    Args:
        engine: SQLCipher engine for the user DB.
        raise_after_stage: If True, raise after load_from_defaults_file
            has staged rows but before the terminal commit. This models
            a mid-write crash / thread death / dispose-triggered error
            whose rollback must leave the DB in a clean pre-write state.
    """
    Session = sessionmaker(bind=engine)
    with Session() as db_session:
        settings_manager = SettingsManager(db_session)
        settings_manager.load_from_defaults_file(commit=False, overwrite=False)
        if raise_after_stage:
            raise RuntimeError("simulated mid-write failure")
        settings_manager.update_db_version(commit=False)
        db_session.commit()


class TestPostLoginSettingsAtomicity:
    """The invariant fixed by commit 621d8d0d must stay fixed."""

    def test_atomic_block_restores_deleted_keys_and_app_version(
        self, user_engine
    ):
        """
        Happy path: if the pre-login state is missing `app.version` and
        some defaults (simulating a version-mismatch + manual-cleanup
        scenario), the atomic block restores everything in one commit.
        """
        _, _, engine = user_engine
        probe_keys = {"app.version", "app.debug"}

        _delete_keys(engine, probe_keys)
        for k in probe_keys:
            assert not _has_setting(engine, k), (
                f"test precondition: {k} deleted"
            )

        _run_post_login_atomic_block(engine)

        for k in probe_keys:
            assert _has_setting(engine, k), (
                f"{k} must be restored after a successful atomic block"
            )

    def test_mid_write_failure_rolls_back_to_pre_write_state(self, user_engine):
        """
        Sticky-loop regression guard. Without atomicity the pre-621d8d0d
        path committed the defaults import first and then tried to
        commit `app.version` separately; any failure between the two
        commits left `app.version` unwritten while staged rows had
        already landed — every subsequent login re-ran the bulk
        insert.

        With the atomic block, a failure between staging and commit
        rolls back ALL staged changes — the probe keys deleted below
        remain deleted, and `app.version` is still absent. Next login
        retries from an unchanged pre-write state.
        """
        _, _, engine = user_engine
        probe_keys = {"app.version", "app.debug"}

        _delete_keys(engine, probe_keys)
        for k in probe_keys:
            assert not _has_setting(engine, k)

        with pytest.raises(RuntimeError, match="simulated mid-write failure"):
            _run_post_login_atomic_block(engine, raise_after_stage=True)

        for k in probe_keys:
            assert not _has_setting(engine, k), (
                f"{k} was deleted before the atomic block; a mid-write "
                f"failure must roll back all staged changes and leave "
                f"it deleted. If it exists now, the two-commit split "
                f"has regressed and the sticky loop is back."
            )

        _run_post_login_atomic_block(engine)
        for k in probe_keys:
            assert _has_setting(engine, k)

    def test_post_login_routes_uses_commit_false_for_both_calls(self):
        """
        Structural guard: lock in that the post-login task body calls
        `load_from_defaults_file` and `update_db_version` with
        `commit=False` and emits a single terminal `db_session.commit()`.
        Any refactor that regresses to the two-commit form will fail
        this test before production sees a sticky loop.

        Inspects `_perform_post_login_tasks_body` (not the decorated
        wrapper) because #3489 split the function into a thin
        try/except wrapper + body for daemon-thread exception logging.
        The atomic block lives in the body.
        """
        import inspect

        from local_deep_research.web.auth import routes

        src = inspect.getsource(routes._perform_post_login_tasks_body)

        assert "load_from_defaults_file(" in src
        assert "update_db_version(commit=False)" in src, (
            "update_db_version must be called with commit=False so the "
            "atomic block controls the terminal commit"
        )
        lfd_idx = src.index("load_from_defaults_file(")
        lfd_tail = src[lfd_idx : lfd_idx + 200]
        assert "commit=False" in lfd_tail, (
            "load_from_defaults_file must be called with commit=False "
            "so the atomic block controls the terminal commit"
        )
        assert "db_session.commit()" in src, (
            "the atomic block must end with a single db_session.commit()"
        )


class TestCheckedOutConnectionSurvivesDispose:
    """Lock in the SA 2.0 contract that PR #3487 misread."""

    @pytest.mark.parametrize("iteration", range(20))
    def test_checked_out_session_completes_after_dispose(
        self, user_engine, iteration
    ):
        """
        Mid-transaction dispose from another thread MUST NOT break the
        writer's session. SA 2.0 `QueuePool.dispose` drains only idle
        entries and `Engine.dispose` calls `pool.recreate()` — a thread
        holding a checked-out connection keeps using it until return.
        20 iterations surface races.
        """
        _, _, engine = user_engine

        key = f"atomicity.test.survival.{iteration}"
        writer_ready = threading.Event()
        main_disposed = threading.Event()
        writer_result = {}

        def writer():
            Session = sessionmaker(bind=engine)
            try:
                with Session() as session:
                    session.add(
                        Setting(
                            key=key,
                            value=f"value_{iteration}",
                            name=key,
                            type="APP",
                            visible=False,
                            editable=False,
                        )
                    )
                    session.flush()
                    writer_ready.set()
                    if not main_disposed.wait(timeout=5.0):
                        writer_result["error"] = (
                            "main thread did not signal dispose within 5s"
                        )
                        return
                    session.commit()
                    writer_result["ok"] = True
            except Exception as exc:
                writer_result["error"] = repr(exc)

        t = threading.Thread(target=writer, daemon=True)
        t.start()

        assert writer_ready.wait(timeout=5.0), (
            f"writer did not start; result={writer_result}"
        )
        engine.dispose()
        main_disposed.set()
        t.join(timeout=10.0)
        assert not t.is_alive(), "writer thread did not finish"

        assert writer_result.get("ok") is True, (
            "engine.dispose() must NOT break a thread holding a "
            "checked-out connection — if this fires, either SA 2.0 "
            "semantics changed or SQLCipher introduced a hook that "
            "violates them. Error: "
            f"{writer_result.get('error')}"
        )

        Session = sessionmaker(bind=engine)
        with Session() as s:
            row = s.query(Setting).filter(Setting.key == key).first()
            assert row is not None, (
                "writer's committed row must be readable from a fresh "
                "session after dispose"
            )
            assert row.value == f"value_{iteration}"

    def test_dispose_without_checked_out_connections_is_noop_for_clients(
        self, user_engine
    ):
        """Sanity: dispose on an idle engine leaves subsequent sessions
        working normally."""
        _, _, engine = user_engine

        Session = sessionmaker(bind=engine)
        with Session() as s:
            s.add(
                Setting(
                    key="atomicity.test.pre_dispose",
                    value="before",
                    name="atomicity.test.pre_dispose",
                    type="APP",
                    visible=False,
                    editable=False,
                )
            )
            s.commit()

        engine.dispose()

        with Session() as s:
            row = (
                s.query(Setting)
                .filter(Setting.key == "atomicity.test.pre_dispose")
                .first()
            )
            assert row is not None
            assert row.value == "before"
            s.add(
                Setting(
                    key="atomicity.test.post_dispose",
                    value="after",
                    name="atomicity.test.post_dispose",
                    type="APP",
                    visible=False,
                    editable=False,
                )
            )
            s.commit()

        with Session() as s:
            row = (
                s.query(Setting)
                .filter(Setting.key == "atomicity.test.post_dispose")
                .first()
            )
            assert row is not None
            assert row.value == "after"
