"""Coverage for the commit-reliable state.db display-cache signature.

The display-merge cache key used to embed ``_state_db_rows_fingerprint``, which
serialises every state.db display row with ``json.dumps``. On a 36k-row session
that cost ~1.4s *per request*, which became the floor cost of even a cache HIT --
the cache could never pay for itself.

``_state_db_session_signature`` now uses a cross-process target-session revision
derived from supported session metadata plus indexed message aggregates. These
tests pin the properties that matter: supported target mutations move the
signature, direct insert/delete/length/timestamp changes remain visible,
unrelated sessions stay stable without a process-local stream marker, and
failures return None so the caller falls back to the exact row fingerprint.
"""

import sqlite3
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from api import routes  # noqa: E402

SID = "20260101_120000_abcdef"


def _build_db(path, rows=None):
    """Minimal messages table matching the columns the signature reads."""
    conn = sqlite3.connect(str(path))
    try:
        conn.execute(
            "CREATE TABLE messages ("
            "  id INTEGER PRIMARY KEY,"
            "  session_id TEXT,"
            "  role TEXT,"
            "  content TEXT,"
            "  timestamp REAL)"
        )
        conn.execute("CREATE INDEX idx_ms ON messages(session_id, id)")
        payload = rows if rows is not None else [
            (i, SID, "user" if i % 2 else "assistant",
             f"message number {i} with enough text to slice into thirds",
             1700000000.0 + i)
            for i in range(1, 41)
        ]
        conn.executemany(
            "INSERT INTO messages (id, session_id, role, content, timestamp) "
            "VALUES (?,?,?,?,?)", payload)
        conn.execute(
            "CREATE TABLE sessions ("
            "  id TEXT PRIMARY KEY,"
            "  message_count INTEGER,"
            "  last_activity_at REAL,"
            "  last_read_at REAL,"
            "  ended_at REAL,"
            "  end_reason TEXT,"
            "  rewind_count INTEGER,"
            "  archived INTEGER)"
        )
        conn.execute(
            "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)",
            (SID, len(payload), 1700000100.0, None, None, None, 0, 0),
        )
        conn.commit()
    finally:
        conn.close()
    return path


@pytest.fixture()
def db(tmp_path, monkeypatch):
    path = _build_db(tmp_path / "state.db")
    monkeypatch.setattr(
        "api.models._agent_state_db_path", lambda profile=None: path, raising=False)
    monkeypatch.setattr(
        "api.models._cli_sessions_streaming_freeze_marker", lambda: None, raising=False)
    return path


def _sig(db_path):
    return routes._state_db_session_signature(SID, None)


def _mutate(db_path, sql, params=(), *, touch_session=False):
    conn = sqlite3.connect(str(db_path))
    try:
        conn.execute(sql, params)
        if touch_session:
            conn.execute(
                "UPDATE sessions SET "
                "message_count=(SELECT COUNT(*) FROM messages WHERE session_id=?), "
                "last_activity_at=COALESCE(last_activity_at, 0)+1 "
                "WHERE id=?",
                (SID, SID),
            )
        conn.commit()
    finally:
        conn.close()


# --------------------------------------------------------------------------
# Detection matrix: every one of these must change the signature, otherwise a
# stale transcript can be served from the display-merge cache.
# --------------------------------------------------------------------------

MUTATIONS = [
    pytest.param(
        "INSERT INTO messages (session_id, role, content, timestamp) "
        "VALUES (?, 'user', 'brand new row', 1700009999.0)", (SID,),
        id="append_row"),
    pytest.param(
        "DELETE FROM messages WHERE rowid = "
        "(SELECT MAX(rowid) FROM messages WHERE session_id = ?)", (SID,),
        id="delete_tail_row"),
    pytest.param(
        "DELETE FROM messages WHERE rowid = "
        "(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
        id="delete_head_row"),
    pytest.param(
        "UPDATE messages SET content = 'short' WHERE rowid = "
        "(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
        id="edit_content_different_length"),
    pytest.param(
        "UPDATE messages SET timestamp = 1.0 WHERE rowid = "
        "(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
        id="edit_timestamp"),
    pytest.param(
        "UPDATE messages SET role = 'tool' WHERE rowid = "
        "(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
        id="edit_role_only"),
    pytest.param(
        "UPDATE messages SET content = 'Z' || SUBSTR(content, 2) WHERE rowid = "
        "(SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
        id="edit_first_char_same_length"),
    pytest.param(
        "UPDATE messages SET content = "
        "SUBSTR(content,1,LENGTH(content)/2-1) || 'Z' || SUBSTR(content,LENGTH(content)/2+1) "
        "WHERE rowid = (SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
        id="edit_middle_char_same_length"),
    pytest.param(
        "UPDATE messages SET content = "
        "SUBSTR(content,1,LENGTH(content)/3-1) || 'Z' || SUBSTR(content,LENGTH(content)/3+1) "
        "WHERE rowid = (SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
        id="edit_third_char_same_length"),
    pytest.param(
        "UPDATE messages SET content = "
        "SUBSTR(content,1,LENGTH(content)-2) || 'Z' || SUBSTR(content,LENGTH(content)) "
        "WHERE rowid = (SELECT MIN(rowid) FROM messages WHERE session_id = ?)", (SID,),
        id="edit_tail_char_same_length"),
]


@pytest.mark.parametrize("sql,params", MUTATIONS)
def test_signature_detects_mutation(db, sql, params):
    """A stale cache is only safe if the key moves on every visible mutation."""
    before = _sig(db)
    assert before is not None
    _mutate(db, sql, params, touch_session=True)
    after = _sig(db)
    assert after is not None
    assert after != before, "signature did not move -> stale transcript risk"


def test_signature_is_stable_without_mutation(db):
    """No spurious invalidation: repeated reads of an unchanged DB must match."""
    first = _sig(db)
    assert first is not None
    for _ in range(3):
        assert _sig(db) == first


def test_signature_is_scoped_to_target_without_process_local_stream_marker(db):
    """Cross-process writes to another session must not churn this cache key."""
    first = _sig(db)
    assert first is not None

    _mutate(
        db,
        "INSERT INTO messages (session_id, role, content, timestamp) "
        "VALUES ('20260202_000000_other', 'user', 'unrelated delta', 1700001111.0)",
    )
    assert _sig(db) == first

    _mutate(
        db,
        "UPDATE messages SET content = 'Z' || SUBSTR(content, 2) WHERE rowid = "
        "(SELECT MIN(rowid) FROM messages WHERE session_id = ?)",
        (SID,),
        touch_session=True,
    )
    assert _sig(db) != first, "supported target edit did not advance session revision"


def test_raw_target_tail_length_change_invalidates_without_session_metadata(db):
    """A direct edit of the indexed tail shape still invalidates."""
    before = _sig(db)
    _mutate(
        db,
        "UPDATE messages SET content = 'short' WHERE rowid = "
        "(SELECT MAX(rowid) FROM messages WHERE session_id = ?)",
        (SID,),
    )
    assert _sig(db) != before


def test_signature_stays_stable_on_other_session_write(db):
    """Session-scoped revision avoids global DB/WAL false invalidations."""
    before = _sig(db)
    _mutate(
        db,
        "INSERT INTO messages (session_id, role, content, timestamp) "
        "VALUES ('20260202_000000_other', 'user', 'unrelated', 1700001111.0)")
    assert _sig(db) == before


# --------------------------------------------------------------------------
# Fail-closed behaviour: the caller must be able to fall back safely.
# --------------------------------------------------------------------------

def test_signature_returns_none_for_unsafe_session_id(db):
    assert routes._state_db_session_signature("../../etc/passwd", None) is None
    assert routes._state_db_session_signature("", None) is None
    assert routes._state_db_session_signature(None, None) is None


def test_signature_returns_none_when_db_missing(tmp_path, monkeypatch):
    missing = tmp_path / "absent.db"
    monkeypatch.setattr(
        "api.models._agent_state_db_path", lambda profile=None: missing, raising=False)
    monkeypatch.setattr(
        "api.models._cli_sessions_streaming_freeze_marker",
        lambda: ("streaming", ("other-active-run",)),
        raising=False,
    )
    assert routes._state_db_session_signature(SID, None) is None


def test_signature_uses_file_fallback_on_incomplete_schema(tmp_path, monkeypatch):
    """File stamps remain a valid key when content summaries are unavailable."""
    path = tmp_path / "state.db"
    conn = sqlite3.connect(str(path))
    try:
        conn.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY)")
        conn.commit()
    finally:
        conn.close()
    monkeypatch.setattr(
        "api.models._agent_state_db_path", lambda profile=None: path, raising=False)
    first = routes._state_db_session_signature(SID, None)
    assert first is not None
    assert routes._state_db_session_signature(SID, None) == first


def test_signature_returns_none_when_all_fingerprints_fail(db, monkeypatch):
    monkeypatch.setattr(
        routes, "_state_db_target_session_revision", lambda *a, **k: None
    )
    monkeypatch.setattr(
        "api.models._sqlite_file_stat_cache_key", lambda path: None, raising=False)
    assert routes._state_db_session_signature(SID, None) is None


def test_signature_returns_none_when_resolver_raises(monkeypatch):
    def boom(profile=None):
        raise RuntimeError("resolver exploded")

    monkeypatch.setattr("api.models._agent_state_db_path", boom, raising=False)
    assert routes._state_db_session_signature(SID, None) is None


def test_cache_key_falls_back_to_exact_fingerprint(tmp_path, monkeypatch):
    """When the bounded signature is unavailable, the key must still be built.

    This is the fail-closed contract: degraded to the old (slow but exact)
    fingerprint rather than disabling the cache or trusting a partial key.
    """
    monkeypatch.setattr(
        routes, "_state_db_session_signature", lambda *a, **k: None)

    calls = []
    real = routes._state_db_rows_fingerprint

    def spy(rows):
        calls.append(len(rows or []))
        return real(rows)

    monkeypatch.setattr(routes, "_state_db_rows_fingerprint", spy)

    class _S:
        session_id = SID
        profile = None
        truncation_watermark = None
        truncation_boundary = None

    monkeypatch.setattr(
        "api.models._sidecar_stat_signature", lambda p: ("sig", 1, 2, 3), raising=False)

    rows = [{"role": "user", "content": "x", "timestamp": 1.0}]
    key = routes._display_merge_cache_key(_S(), [{"timestamp": 1.0}], rows)

    assert calls, "exact fingerprint was not used as fallback"
    assert key is not None
