"""Regressions for #76354 review S1/S2/S4 — activity write budget, watchdog
pre-delivery revalidation, and import/export activity asymmetry.
"""

from __future__ import annotations

import sqlite3
import threading
import time
from pathlib import Path
from types import SimpleNamespace

import pytest

from agent.session_activity import ActivityProvenance, build_activity_snapshot
from hermes_state import SessionDB


def _activity_snapshot(db, session_id):
    """Durable activity snapshot for *session_id* (what gateway/delegate readers build from the row)."""
    row = db.get_session(session_id)
    return build_activity_snapshot(
        last_activity_at=row.get("last_activity_at"),
        last_activity_description=row.get("last_activity_description"),
        last_activity_provenance=row.get("last_activity_provenance"),
    )


# ── S1: observational activity writes must not ride the 20s patience ────────


def _hold_write_lock(db_path: Path, held: threading.Event, release: threading.Event):
    conn = sqlite3.connect(str(db_path), timeout=30)
    try:
        conn.execute("BEGIN IMMEDIATE")
        held.set()
        release.wait(timeout=30)
        conn.rollback()
    finally:
        conn.close()


def test_s1_contended_activity_write_gives_up_within_short_budget(tmp_path):
    db = SessionDB(db_path=tmp_path / "state.db")
    sid = "S1_CONTENDED"
    db.create_session(sid, source="cli")

    held = threading.Event()
    release = threading.Event()
    locker = threading.Thread(
        target=_hold_write_lock, args=(tmp_path / "state.db", held, release)
    )
    locker.start()
    try:
        assert held.wait(timeout=5)
        t0 = time.monotonic()
        with pytest.raises(sqlite3.OperationalError):
            db.touch_session_activity(sid, time.time(), description="working")
        elapsed_touch = time.monotonic() - t0
    finally:
        release.set()
        locker.join(timeout=10)

    # The observational write gave up within the short budget — far below
    # the 20s routine patience the review flagged.
    assert elapsed_touch < 3.0, f"activity touch waited {elapsed_touch:.1f}s"


def test_s1_clear_labels_noop_skips_transaction(tmp_path, monkeypatch):
    db = SessionDB(db_path=tmp_path / "state.db")
    sid = "S1_NOOP"
    db.create_session(sid, source="cli")
    # Fresh session: labels empty → the clear must not open a transaction.
    calls = []
    original = db._execute_write

    def _spy(fn, patience_s=None):
        calls.append(fn)
        return original(fn, patience_s=patience_s)

    monkeypatch.setattr(db, "_execute_write", _spy)
    db.clear_session_activity_labels(sid)
    assert calls == [], "no-op label clear must skip the write transaction"

    # Non-empty labels → clear runs exactly one write.
    db.touch_session_activity(sid, time.time(), description="doing work")
    calls.clear()
    db.clear_session_activity_labels(sid)
    assert len(calls) == 1
    activity = _activity_snapshot(db, sid)
    assert activity["last_activity_description"] == ""


def test_s1_contended_clear_gives_up_within_short_budget(tmp_path):
    db = SessionDB(db_path=tmp_path / "state.db")
    sid = "S1_CLEAR_CONTENDED"
    db.create_session(sid, source="cli")
    db.touch_session_activity(sid, time.time(), description="busy")

    held = threading.Event()
    release = threading.Event()
    locker = threading.Thread(
        target=_hold_write_lock, args=(tmp_path / "state.db", held, release)
    )
    locker.start()
    try:
        assert held.wait(timeout=5)
        t0 = time.monotonic()
        with pytest.raises(sqlite3.OperationalError):
            db.clear_session_activity_labels(sid)
        elapsed = time.monotonic() - t0
    finally:
        release.set()
        locker.join(timeout=10)
    assert elapsed < 3.0, f"label clear waited {elapsed:.1f}s under contention"


# ── S2: watchdog revalidates immediately before /new delivery ────────────────


class _FakeAdapter:
    def __init__(self):
        self._pending_messages = {}
        self.sent = []

    async def send(self, chat_id, content, metadata=None):
        self.sent.append({"chat_id": chat_id, "content": content})


class _RacingAgent:
    """Reports stale activity on the first read, fresh on the second.

    Models an agent that makes progress between the watchdog's candidate
    scan and its delivery attempt.
    """

    def __init__(self):
        self.reads = 0

    def get_activity_summary(self):
        self.reads += 1
        age = 999 if self.reads == 1 else 1
        return build_activity_snapshot(
            last_activity_at=time.time() - age,
            last_activity_description="api call",
            last_activity_provenance=ActivityProvenance.UNKNOWN,
        )


def _runner_for_stall(adapter):
    from gateway.run import GatewayRunner

    r = GatewayRunner.__new__(GatewayRunner)
    r._running = True
    r.adapters = {"fake": adapter}
    r._profile_adapters = {}
    r._running_agents = {}
    r._running_agents_ts = {}
    r._queued_events = {}
    r._session_stall_notified = {}
    r._thread_metadata_for_source = lambda source, *a, **k: {}
    return r


def _pending_event(chat_id="chat-1"):
    source = SimpleNamespace(chat_id=chat_id, thread_id=None, platform=None)
    return SimpleNamespace(text="follow-up", source=source, timestamp=time.time())


@pytest.mark.asyncio
async def test_s2_progress_between_scan_and_send_aborts_delivery():
    adapter = _FakeAdapter()
    runner = _runner_for_stall(adapter)
    session_key = "agent:main:telegram:dm:race"
    adapter._pending_messages[session_key] = _pending_event()
    agent = _RacingAgent()
    runner._running_agents[session_key] = agent

    sent = await runner._check_session_stalls(60)
    # First read said stale; the pre-delivery re-read said fresh → abort.
    assert sent == 0
    assert adapter.sent == []
    assert agent.reads >= 2, "watchdog must re-read activity before delivery"
    # Latch re-armed: a future genuine stall must still notify.
    assert session_key not in runner._session_stall_notified


@pytest.mark.asyncio
async def test_s2_pending_drained_between_scan_and_send_aborts_delivery():
    adapter = _FakeAdapter()
    runner = _runner_for_stall(adapter)
    session_key = "agent:main:telegram:dm:drain"

    class _DrainOnReadAgent:
        def __init__(self):
            self.reads = 0

        def get_activity_summary(self):
            self.reads += 1
            if self.reads == 1:
                # Simulate the queue draining after the candidate scan but
                # before the pre-delivery revalidation.
                adapter._pending_messages.pop(session_key, None)
            return build_activity_snapshot(
                last_activity_at=time.time() - 999,
                last_activity_description="api call",
                last_activity_provenance=ActivityProvenance.UNKNOWN,
            )

    adapter._pending_messages[session_key] = _pending_event()
    runner._running_agents[session_key] = _DrainOnReadAgent()

    sent = await runner._check_session_stalls(60)
    assert sent == 0
    assert adapter.sent == []


@pytest.mark.asyncio
async def test_s2_still_stale_after_revalidation_delivers():
    """Sanity: revalidation must not suppress GENUINE stall notices."""
    adapter = _FakeAdapter()
    runner = _runner_for_stall(adapter)
    session_key = "agent:main:telegram:dm:genuine"
    adapter._pending_messages[session_key] = _pending_event()

    class _StaleAgent:
        def get_activity_summary(self):
            return build_activity_snapshot(
                last_activity_at=time.time() - 999,
                last_activity_description="api call",
                last_activity_provenance=ActivityProvenance.UNKNOWN,
            )

    runner._running_agents[session_key] = _StaleAgent()
    sent = await runner._check_session_stalls(60)
    assert sent == 1
    assert adapter.sent and "/new" in adapter.sent[0]["content"]


# ── S4: export includes activity fields; import resets them ─────────────────


def test_s4_export_includes_activity_import_resets_it(tmp_path):
    src = SessionDB(db_path=tmp_path / "src.db")
    sid = "S4_PORTABILITY"
    src.create_session(sid, source="cli")
    src.append_message(sid, "user", "hello")
    src.touch_session_activity(
        sid,
        time.time(),
        description="working on something",
        provenance=ActivityProvenance.AGENT_COMPRESSION,
    )

    exported = src.export_session(sid)
    # Export INCLUDES the live activity fields (part of the durable row).
    assert exported["last_activity_at"] is not None
    assert exported["last_activity_description"] == "working on something"

    dst = SessionDB(db_path=tmp_path / "dst.db")
    result = dst.import_sessions([exported])
    assert sid in result.get("imported_ids", result.get("imported", [sid]))

    row = dst.get_session(sid)
    # Import RESETS activity: no resurrected "working" label on a machine
    # where no agent is running (explicit contract, #76354 S4).
    assert row.get("last_activity_at") is None
    assert not row.get("last_activity_description")
    assert not row.get("last_activity_provenance")
