"""
Tests for Phase 1: Real-time Gateway Session Sync.

Tests are ordered TDD-style:
  1. Gateway sessions appear in /api/sessions when setting enabled
  2. Gateway sessions excluded when setting disabled
  3. Gateway sessions have correct metadata (source_tag, is_cli_session)
  4. SSE stream endpoint opens and receives events
  5. Watcher detects new sessions inserted into state.db
  6. Settings UI has renamed label
"""
import json
import os
import pathlib
import subprocess
import sqlite3
import time
import urllib.error
import urllib.request

import pytest

REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
from tests._pytest_port import BASE


def get(path, *, profile=None):
    headers = {}
    if profile:
        headers["Cookie"] = f"hermes_profile={profile}"
    req = urllib.request.Request(BASE + path, headers=headers)
    with urllib.request.urlopen(req, timeout=10) as r:
        return json.loads(r.read()), r.status


def post(path, body=None, *, profile=None):
    data = json.dumps(body or {}).encode()
    headers = {"Content-Type": "application/json"}
    if profile:
        headers["Cookie"] = f"hermes_profile={profile}"
    req = urllib.request.Request(BASE + path, data=data, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=10) as r:
            return json.loads(r.read()), r.status
    except urllib.error.HTTPError as e:
        try:
            return json.loads(e.read()), e.code
        except Exception:
            return {}, e.code


def _get_test_state_dir():
    """Return the test state directory (matches conftest.py TEST_STATE_DIR).

    conftest.py sets HERMES_WEBUI_TEST_STATE_DIR in the test-process environment
    (via os.environ.setdefault) so that tests writing directly to state.db always
    use the same path the test server was started with.  If the env var is not
    set (e.g. when running this file standalone), fall back to the conftest
    formula via _pytest_port (temp-rooted, never ~/.hermes).
    """
    # Use _pytest_port which applies the same auto-derivation as conftest.py
    from tests._pytest_port import TEST_STATE_DIR as _ptsd
    return _ptsd


def _get_profile_state_dir(profile=None):
    state_dir = _get_test_state_dir()
    if isinstance(profile, str) and profile.strip():
        return state_dir / 'profiles' / profile.strip()
    return state_dir


def _get_state_db_path(profile=None):
    """Return path to the test state.db."""
    return _get_profile_state_dir(profile) / 'state.db'


def _ensure_state_db(profile=None):
    """Create state.db with sessions and messages tables if it doesn't exist.
    Returns a connection. Does NOT delete existing data (safe for parallel tests).
    """
    db_path = _get_state_db_path(profile)
    db_path.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(str(db_path))
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS sessions (
            id TEXT PRIMARY KEY,
            source TEXT NOT NULL,
            user_id TEXT,
            model TEXT,
            started_at REAL NOT NULL,
            message_count INTEGER DEFAULT 0,
            title TEXT
        );
        CREATE TABLE IF NOT EXISTS messages (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            session_id TEXT NOT NULL,
            role TEXT NOT NULL,
            content TEXT,
            timestamp REAL NOT NULL
        );
    """)
    for column, ddl in (
        ('user_id', 'ALTER TABLE sessions ADD COLUMN user_id TEXT'),
        ('chat_id', 'ALTER TABLE sessions ADD COLUMN chat_id TEXT'),
        ('chat_type', 'ALTER TABLE sessions ADD COLUMN chat_type TEXT'),
        ('thread_id', 'ALTER TABLE sessions ADD COLUMN thread_id TEXT'),
        ('session_key', 'ALTER TABLE sessions ADD COLUMN session_key TEXT'),
        ('origin_chat_id', 'ALTER TABLE sessions ADD COLUMN origin_chat_id TEXT'),
        ('origin_user_id', 'ALTER TABLE sessions ADD COLUMN origin_user_id TEXT'),
        ('platform', 'ALTER TABLE sessions ADD COLUMN platform TEXT'),
        ('parent_session_id', 'ALTER TABLE sessions ADD COLUMN parent_session_id TEXT'),
        ('ended_at', 'ALTER TABLE sessions ADD COLUMN ended_at REAL'),
        ('end_reason', 'ALTER TABLE sessions ADD COLUMN end_reason TEXT'),
    ):
        existing = {row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()}
        if column not in existing:
            conn.execute(ddl)
    conn.commit()
    return conn


def _insert_gateway_session(conn, session_id='20260401_120000_abcdefgh', source='telegram',
                             title='Telegram Chat', model='anthropic/claude-sonnet-4-5',
                             started_at=None, message_count=2, user_id=None, chat_id=None,
                             chat_type=None, thread_id=None, session_key=None, origin_chat_id=None,
                             origin_user_id=None, platform=None):
    """Insert a gateway session into state.db."""
    conn.execute(
        "INSERT OR REPLACE INTO sessions (id, source, user_id, title, model, started_at, message_count) "
        "VALUES (?, ?, ?, ?, ?, ?, ?)",
        (session_id, source, user_id, title, model, started_at or time.time(), message_count)
    )
    updates = []
    params = []
    for key, value in (
        ("chat_id", chat_id),
        ("chat_type", chat_type),
        ("thread_id", thread_id),
        ("session_key", session_key),
        ("origin_chat_id", origin_chat_id),
        ("origin_user_id", origin_user_id),
        ("platform", platform),
    ):
        if value is not None:
            updates.append(f"{key} = ?")
            params.append(value)
    if updates:
        conn.execute(
            f"UPDATE sessions SET {', '.join(updates)} WHERE id = ?",
            [*params, session_id]
        )
    # Delete any existing messages for this session (idempotent re-insert)
    conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
    # Insert some messages
    conn.execute(
        "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'user', ?, ?)",
        (session_id, 'Hello from Telegram', started_at or time.time())
    )
    conn.execute(
        "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'assistant', ?, ?)",
        (session_id, 'Hi there!', (started_at or time.time()) + 1)
    )
    conn.commit()


def _insert_agent_session_row(
    conn,
    session_id,
    source='weixin',
    title='Agent Session',
    model='openai/gpt-5',
    started_at=None,
    parent_session_id=None,
    ended_at=None,
    end_reason=None,
    messages=1,
):
    """Insert an agent session row with optional compression lineage."""
    started_at = started_at or time.time()
    conn.execute(
        "INSERT OR REPLACE INTO sessions "
        "(id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) "
        "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (
            session_id,
            source,
            title,
            model,
            started_at,
            messages,
            parent_session_id,
            ended_at,
            end_reason,
        ),
    )
    conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
    for i in range(messages):
        conn.execute(
            "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
            (
                session_id,
                'user' if i % 2 == 0 else 'assistant',
                f'{title} message {i + 1}',
                started_at + i,
            ),
        )
    conn.commit()


def _remove_test_sessions(conn, *session_ids):
    """Remove specific test sessions from state.db (parallel-safe cleanup)."""
    for sid in session_ids:
        conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
        conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
    conn.commit()


def _cleanup_state_db():
    """Remove state.db if it exists (only used for tests that need a blank slate)."""
    db_path = _get_state_db_path()
    for p in [db_path, db_path.parent / 'state.db-wal', db_path.parent / 'state.db-shm']:
        try:
            p.unlink(missing_ok=True)
        except Exception:
            pass


def _insert_message(conn, sid, role, content, timestamp):
    conn.execute(
        "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
        (sid, role, content, timestamp),
    )


# ── Tests ──────────────────────────────────────────────────────────────────

def test_gateway_sessions_appear_when_enabled():
    """Gateway sessions from state.db appear in /api/sessions when show_cli_sessions is on."""
    conn = _ensure_state_db()
    try:
        _insert_gateway_session(conn, session_id='gw_test_tg_001', source='telegram', title='TG Test Chat')

        # Enable the setting
        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_tg_001']
        assert len(gw_ids) == 1, f"Expected gateway session gw_test_tg_001, got {[s['session_id'] for s in sessions]}"
    finally:
        try:
            _remove_test_sessions(conn, 'gw_test_tg_001')
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_webui_state_db_session_without_sidecar_appears_when_agent_sessions_enabled():
    """Regression: state.db rows without JSON sidecars can be recovered via agent bridge."""
    conn = _ensure_state_db()
    sid = 'cli_state_only_001'
    try:
        _insert_agent_session_row(
            conn,
            session_id=sid,
            source='cli',
            title='Recovered CLI Session',
            model='openai/gpt-5',
            messages=2,
        )

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        recovered = [s for s in sessions if s.get('session_id') == sid]
        assert len(recovered) == 1, (
            "State.db sessions without a JSON sidecar "
            "should be surfaced through the agent-session bridge for recovery."
        )
        assert recovered[0].get('source_tag') == 'cli'
        assert recovered[0].get('is_cli_session') is True
    finally:
        try:
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_active_cli_state_db_session_with_persisted_user_turn_is_visible_in_cli_bucket():
    """Active default-title CLI rows with persisted user content stay visible in the CLI bucket."""
    conn = _ensure_state_db()
    active_sid = 'cli_active_visible_001'
    older_sid = 'cli_older_visible_001'
    ended_sid = 'cli_ended_hidden_001'
    now = time.time()
    try:
        conn.execute(
            "INSERT OR REPLACE INTO sessions "
            "(id, source, title, model, started_at, message_count, ended_at, end_reason) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
            (active_sid, 'cli', 'Untitled', 'openai/gpt-5', now + 20, 0, None, None),
        )
        conn.execute("DELETE FROM messages WHERE session_id = ?", (active_sid,))
        _insert_message(conn, active_sid, 'user', 'Active CLI session still running', now + 21)

        conn.execute(
            "INSERT OR REPLACE INTO sessions "
            "(id, source, title, model, started_at, message_count, ended_at, end_reason) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
            (older_sid, 'cli', 'Named CLI Session', 'openai/gpt-5', now, 1, None, None),
        )
        conn.execute("DELETE FROM messages WHERE session_id = ?", (older_sid,))
        _insert_message(conn, older_sid, 'user', 'Older visible CLI session', now + 1)

        conn.execute(
            "INSERT OR REPLACE INTO sessions "
            "(id, source, title, model, started_at, message_count, ended_at, end_reason) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
            (ended_sid, 'cli', 'Untitled', 'openai/gpt-5', now + 10, 1, now + 11, 'cli-close'),
        )
        conn.execute("DELETE FROM messages WHERE session_id = ?", (ended_sid,))
        _insert_message(conn, ended_sid, 'user', 'Ended CLI session', now + 11)
        conn.commit()

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions?sidebar_source=cli')
        assert status == 200
        sessions = data.get('sessions', [])
        session_ids = [s.get('session_id') for s in sessions]
        assert active_sid in session_ids
        assert older_sid in session_ids
        assert ended_sid not in session_ids, "ended default-title CLI rows with one user turn stay hidden"

        active = next(s for s in sessions if s.get('session_id') == active_sid)
        older = next(s for s in sessions if s.get('session_id') == older_sid)
        assert active.get('message_count') == 1
        assert active.get('updated_at') > older.get('updated_at')
        assert session_ids.index(active_sid) < session_ids.index(older_sid)
    finally:
        try:
            _remove_test_sessions(conn, active_sid, older_sid, ended_sid)
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_gateway_sessions_without_messages_are_hidden_from_sidebar():
    """Regression: empty agent session rows must not appear as broken sidebar entries."""
    conn = _ensure_state_db()
    empty_sid = 'gw_empty_no_messages_001'
    try:
        conn.execute(
            "INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (empty_sid, 'cron', 'Cron Session', 'openai/gpt-5', time.time(), 0),
        )
        conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
        conn.commit()

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        assert empty_sid not in {s.get('session_id') for s in sessions}, (
            "Agent sessions with no readable message rows should be filtered before "
            "they reach the sidebar; otherwise clicking them fails during import."
        )
    finally:
        try:
            _remove_test_sessions(conn, empty_sid)
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_gateway_watcher_hides_sessions_without_messages(monkeypatch):
    """Regression: SSE watcher must use the same importable-agent filter."""
    conn = _ensure_state_db()
    empty_sid = 'gw_empty_watcher_001'
    live_sid = 'gw_live_watcher_001'
    try:
        conn.execute(
            "INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (empty_sid, 'telegram', 'Empty Telegram Session', 'openai/gpt-5', time.time(), 0),
        )
        conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
        _insert_gateway_session(
            conn,
            session_id=live_sid,
            source='telegram',
            title='Live Telegram Session',
            message_count=0,
        )

        import api.gateway_watcher as gateway_watcher

        monkeypatch.setattr(gateway_watcher, '_get_state_db_path', _get_state_db_path)

        sessions = gateway_watcher._get_agent_sessions_from_db()
        ids = {s.get('session_id') for s in sessions}
        live = next((s for s in sessions if s.get('session_id') == live_sid), None)

        assert empty_sid not in ids
        assert live is not None
        assert live.get('message_count') == 2, (
            "Watcher should fall back to actual message rows when stored "
            "message_count is zero, matching the sidebar route."
        )
    finally:
        try:
            _remove_test_sessions(conn, empty_sid, live_sid)
            conn.close()
        except Exception:
            pass


def test_compression_chain_collapses_to_latest_tip_in_sidebar():
    """Show one logical agent conversation for a compression continuation chain."""
    conn = _ensure_state_db()
    ids_to_remove = ('chain_root_001', 'chain_empty_mid_001', 'chain_tip_001')
    t0 = time.time() - 600
    try:
        _insert_agent_session_row(
            conn,
            'chain_root_001',
            title='Magazine Style PPT Skill',
            started_at=t0,
            ended_at=t0 + 100,
            end_reason='compression',
            messages=3,
        )
        _insert_agent_session_row(
            conn,
            'chain_empty_mid_001',
            title='Magazine Style PPT Skill #2',
            started_at=t0 + 101,
            parent_session_id='chain_root_001',
            ended_at=t0 + 200,
            end_reason='compression',
            messages=0,
        )
        _insert_agent_session_row(
            conn,
            'chain_tip_001',
            title='Magazine Style PPT Skill #3',
            started_at=t0 + 201,
            parent_session_id='chain_empty_mid_001',
            messages=2,
        )

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s.get('session_id') for s in data.get('sessions', [])}
        tip = next((s for s in data.get('sessions', []) if s.get('session_id') == 'chain_tip_001'), None)

        assert 'chain_tip_001' in ids
        assert 'chain_root_001' not in ids
        assert 'chain_empty_mid_001' not in ids
        assert tip is not None
        assert tip.get('title') == 'Magazine Style PPT Skill'
        assert tip.get('message_count') == 2
        # created_at = the chain head's started_at (preserves original conversation date)
        assert abs(tip.get('created_at') - t0) < 0.01
        # updated_at = the tip's last message timestamp so the sidebar entry
        # bubbles to the top by true recency, not by the root's stale activity.
        # tip messages are at t0+201 and t0+202, so last_activity = t0 + 202.
        assert abs(tip.get('updated_at') - (t0 + 202)) < 0.01
        assert tip.get('_lineage_root_id') == 'chain_root_001'
        assert tip.get('_lineage_tip_id') == 'chain_tip_001'
        assert tip.get('_compression_segment_count') == 3

        from api.agent_sessions import read_importable_agent_session_rows

        rows = read_importable_agent_session_rows(_get_state_db_path(), limit=None)
        projected_tip = next((row for row in rows if row.get('id') == 'chain_tip_001'), None)
        assert projected_tip is not None
        assert projected_tip.get('title') == 'Magazine Style PPT Skill'
        assert projected_tip.get('_lineage_root_id') == 'chain_root_001'
        assert projected_tip.get('_lineage_tip_id') == 'chain_tip_001'
        assert projected_tip.get('_compression_segment_count') == 3
    finally:
        try:
            _remove_test_sessions(conn, *ids_to_remove)
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


@pytest.mark.parametrize('reset_marker_origin', ('creation', 'reopen'))
def test_reopen_then_recompress_reset_child_stays_independent_across_readers(
    tmp_path, monkeypatch, reset_marker_origin
):
    """Real Agent lifecycle: reset child, ``reopen_session()``, later compression.

    Driven through the installed ``SessionDB`` so the rows carry exactly the
    production shape. ``creation`` mirrors the gateway creating the reset child
    with ``_reset_from`` in its INSERT; ``reopen`` mirrors a marker-less legacy
    reset child that ``reopen_session()`` durably stamps. Either way the parent
    ends up ``end_reason='compression'`` with a live reset child, and every
    WebUI reader must agree: the reset conversation stays visible and its
    transcript is never prefixed with the parent's.

    The WebUI readers are the contract under test. The Agent's own
    ``find_live_compression_child`` / ``get_compression_tip`` verdict is only
    cross-checked when the installed Agent already binds ``_reset_from`` in
    its non-continuation child predicate; an older Agent whose predicate
    predates that marker still has to drive the same fixture without turning
    this WebUI test red.
    """
    hermes_state = pytest.importorskip('hermes_state')
    # SessionDB lazily imports sibling Agent modules (e.g. hermes_logging);
    # keep the Agent checkout importable even after the suite's sys.path
    # isolation guard restored an earlier snapshot.
    monkeypatch.syspath_prepend(str(pathlib.Path(hermes_state.__file__).resolve().parent))
    import api.models as models
    from api.agent_sessions import (
        read_importable_agent_session_rows,
        read_session_lineage_metadata,
        read_session_lineage_report,
    )
    from api.gateway_watcher import GatewayWatcher
    from api.models import get_state_db_session_messages

    parent_id = 'recompressed_reset_parent'
    child_id = 'reset_child_conversation'
    routing = {'session_key': 'telegram:reset-peer', 'user_id': 'peer', 'chat_id': 'chat-1'}
    db_path = tmp_path / 'state.db'
    db = hermes_state.SessionDB(db_path=db_path)
    try:
        db.create_session(parent_id, source='telegram', model='test-model', **routing)
        db.append_messages_batch(
            parent_id,
            [
                {'role': 'user', 'content': 'before reset', 'timestamp': 100.0},
                {'role': 'assistant', 'content': 'answer before reset', 'timestamp': 101.0},
            ],
        )
        assert db.promote_to_session_reset(parent_id) is True
        child_model_config = (
            {'_reset_from': parent_id} if reset_marker_origin == 'creation' else None
        )
        db.create_session(
            child_id,
            source='telegram',
            model='test-model',
            parent_session_id=parent_id,
            model_config=child_model_config,
            **routing,
        )
        db.append_messages_batch(
            child_id,
            [
                {'role': 'user', 'content': 'after reset', 'timestamp': 200.0},
                {'role': 'assistant', 'content': 'answer after reset', 'timestamp': 201.0},
            ],
        )
        if reset_marker_origin == 'reopen':
            assert db.get_session(child_id)['model_config'] is None
        # The user resumes the old conversation: reopen_session() durably
        # stamps _reset_from on the legacy reset child before clearing the
        # parent's reset boundary. Should an installed Agent predate that
        # stamping, apply the same schema the upstream Agent writes
        # (``model_config._reset_from = parent_session_id``) so the rows still
        # carry the production shape the WebUI readers are contracted against.
        db.reopen_session(parent_id)
        expected_child_config = {'_reset_from': parent_id}
        if json.loads(db.get_session(child_id)['model_config'] or '{}') != expected_child_config:
            with sqlite3.connect(str(db_path)) as conn:
                conn.execute(
                    "UPDATE sessions SET model_config = ? WHERE id = ?",
                    (json.dumps(expected_child_config), child_id),
                )
        assert json.loads(db.get_session(child_id)['model_config']) == expected_child_config
        reopened = db.get_session(parent_id)
        assert reopened['ended_at'] is None and reopened['end_reason'] is None
        db.append_messages_batch(
            parent_id,
            [{'role': 'user', 'content': 'resumed parent', 'timestamp': 300.0}],
        )
        # ...and that resumed parent is later closed by compression.
        db.end_session(parent_id, 'compression')
        assert db.get_session(parent_id)['end_reason'] == 'compression'
        # Agent cross-check, only when the installed Agent's child predicate
        # already binds _reset_from: the reset child is not a compression
        # continuation there either. Older Agents are not this test's subject.
        agent_filter_sql = str(
            getattr(hermes_state.SessionDB, '_NON_CONTINUATION_CHILD_FILTER_SQL', '')
        )
        if '_reset_from' in agent_filter_sql:
            assert db.find_live_compression_child(parent_id) is None
            assert db.get_compression_tip(parent_id) == parent_id
    finally:
        db.close()

    with sqlite3.connect(str(db_path)) as conn:
        conn.execute(
            "UPDATE sessions SET title = ? WHERE id = ?",
            ('Reset parent conversation', parent_id),
        )
        conn.execute(
            "UPDATE sessions SET title = ? WHERE id = ?",
            ('Reset child conversation', child_id),
        )

    # (a) Sidebar projection: both rows visible; the child keeps its own
    # title and is a plain child_session, never a hidden continuation.
    projected = read_importable_agent_session_rows(db_path, limit=None, exclude_sources=None)
    projected_by_id = {row['id']: row for row in projected}
    assert set(projected_by_id) == {parent_id, child_id}
    child_row = projected_by_id[child_id]
    assert child_row['title'] == 'Reset child conversation'
    assert child_row['relationship_type'] == 'child_session'
    assert child_row['parent_session_id'] == parent_id
    assert '_lineage_root_id' not in child_row
    parent_row = projected_by_id[parent_id]
    assert parent_row['title'] == 'Reset parent conversation'
    assert '_lineage_tip_id' not in parent_row
    assert '_compression_segment_count' not in parent_row

    # (b) Lineage metadata and report.
    metadata = read_session_lineage_metadata(db_path, {parent_id, child_id})
    assert metadata[child_id]['relationship_type'] == 'child_session'
    assert '_lineage_root_id' not in metadata[child_id]
    assert '_lineage_tip_id' not in metadata.get(parent_id, {})
    child_report = read_session_lineage_report(db_path, child_id)
    assert child_report['lineage_key'] == child_id
    assert child_report['tip_session_id'] == child_id
    assert child_report['total_segments'] == 1
    parent_report = read_session_lineage_report(db_path, parent_id)
    assert parent_report['tip_session_id'] == parent_id
    assert parent_report['total_segments'] == 1
    assert [child['session_id'] for child in parent_report['children']] == [child_id]
    assert parent_report['children'][0]['role'] == 'child_session'

    # (c) Watcher publication: the SSE payload carries both conversations
    # with their own titles.
    watcher = GatewayWatcher(state_db_path=db_path)
    subscriber = watcher.subscribe()
    assert watcher._poll_once(now=1.0) is True
    event = subscriber.get_nowait()
    published = {row['session_id']: row for row in event['sessions']}
    assert set(published) == {parent_id, child_id}
    assert published[child_id]['title'] == 'Reset child conversation'
    assert published[parent_id]['title'] == 'Reset parent conversation'

    # (d) Transcript stitching never prepends the parent's transcript.
    monkeypatch.setattr(models, '_active_state_db_path', lambda: db_path)
    stitched = get_state_db_session_messages(child_id, stitch_continuations=True)
    assert [message['content'] for message in stitched] == [
        'after reset',
        'answer after reset',
    ]
    parent_messages = get_state_db_session_messages(parent_id, stitch_continuations=True)
    assert [message['content'] for message in parent_messages] == [
        'before reset',
        'answer before reset',
        'resumed parent',
    ]


def test_compression_lineage_prefers_freshest_descendant_over_newer_direct_sibling():
    """A later-started stale sibling must not hide a deeper active branch."""
    conn = _ensure_state_db()
    ids_to_remove = (
        'branch_root_001',
        'branch_old_mid_001',
        'branch_fresh_tip_001',
        'branch_empty_stale_tip_001',
        'branch_newer_direct_sibling_001',
    )
    t0 = time.time() - 800
    try:
        _insert_agent_session_row(
            conn,
            'branch_root_001',
            title='Qwen Routing Audit',
            started_at=t0,
            ended_at=t0 + 100,
            end_reason='compression',
            messages=2,
        )
        _insert_agent_session_row(
            conn,
            'branch_old_mid_001',
            title='Qwen Routing Audit #2',
            started_at=t0 + 101,
            parent_session_id='branch_root_001',
            ended_at=t0 + 200,
            end_reason='compression',
            messages=2,
        )
        _insert_agent_session_row(
            conn,
            'branch_newer_direct_sibling_001',
            title='Qwen Routing Audit #3 stale sibling',
            started_at=t0 + 150,
            parent_session_id='branch_root_001',
            messages=2,
        )
        _insert_agent_session_row(
            conn,
            'branch_fresh_tip_001',
            title='Qwen Routing Audit #4 freshest tip',
            started_at=t0 + 500,
            parent_session_id='branch_old_mid_001',
            messages=2,
        )
        _insert_agent_session_row(
            conn,
            'branch_empty_stale_tip_001',
            title='Qwen Routing Audit #5 empty stale tip',
            started_at=t0 + 700,
            parent_session_id='branch_old_mid_001',
            messages=0,
        )
        conn.execute(
            "UPDATE sessions SET message_count = 3 WHERE id = ?",
            ('branch_empty_stale_tip_001',),
        )
        conn.commit()

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s.get('session_id') for s in data.get('sessions', [])}
        tip = next((s for s in data.get('sessions', []) if s.get('session_id') == 'branch_fresh_tip_001'), None)

        assert 'branch_fresh_tip_001' in ids
        assert 'branch_newer_direct_sibling_001' not in ids
        assert 'branch_empty_stale_tip_001' not in ids
        assert tip is not None
        assert tip.get('title') == 'Qwen Routing Audit'
        assert abs(tip.get('updated_at') - (t0 + 501)) < 0.01
        assert tip.get('_lineage_root_id') == 'branch_root_001'
        assert tip.get('_lineage_tip_id') == 'branch_fresh_tip_001'
        assert tip.get('_compression_segment_count') == 5

        from api.agent_sessions import read_importable_agent_session_rows

        rows = read_importable_agent_session_rows(_get_state_db_path(), limit=None)
        projected_tip = next((row for row in rows if row.get('id') == 'branch_fresh_tip_001'), None)
        assert projected_tip is not None
        assert projected_tip.get('_lineage_root_id') == 'branch_root_001'
        assert projected_tip.get('_lineage_tip_id') == 'branch_fresh_tip_001'
        assert projected_tip.get('_compression_segment_count') == 5

        from api.agent_sessions import read_session_lineage_metadata

        metadata = read_session_lineage_metadata(
            _get_state_db_path(),
            {'branch_old_mid_001', 'branch_fresh_tip_001', 'branch_empty_stale_tip_001', 'branch_newer_direct_sibling_001'},
        )
        assert metadata['branch_old_mid_001'].get('_lineage_tip_id') == 'branch_fresh_tip_001'
        assert metadata['branch_empty_stale_tip_001'].get('_lineage_tip_id') == 'branch_fresh_tip_001'
        assert metadata['branch_newer_direct_sibling_001'].get('_lineage_tip_id') == 'branch_fresh_tip_001'
        assert metadata['branch_newer_direct_sibling_001'].get('_compression_segment_count') == 5
    finally:
        try:
            _remove_test_sessions(conn, *ids_to_remove)
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_compression_projection_handles_deep_lineage_iteratively():
    """Very deep compression chains should not depend on Python recursion depth."""
    from api.agent_sessions import _project_agent_session_rows

    rows = []
    previous = None
    for idx in range(1100):
        sid = f'deep_chain_{idx:04d}'
        started_at = float(idx * 2)
        rows.append({
            'id': sid,
            'title': 'Deep Chain' if idx == 0 else f'Deep Chain #{idx + 1}',
            'source': 'cli',
            'started_at': started_at,
            'parent_session_id': previous,
            'ended_at': started_at + 1 if idx < 1099 else None,
            'end_reason': 'compression' if idx < 1099 else None,
            'actual_message_count': 1,
            'actual_user_message_count': 1,
            'message_count': 1,
            'last_activity': started_at + 0.5,
        })
        previous = sid

    projected = _project_agent_session_rows(rows)

    assert len(projected) == 1
    assert projected[0]['id'] == 'deep_chain_1099'
    assert projected[0]['_lineage_root_id'] == 'deep_chain_0000'
    assert projected[0]['_lineage_tip_id'] == 'deep_chain_1099'
    assert projected[0]['_compression_segment_count'] == 1100


def test_compression_chain_with_empty_latest_tip_falls_back_to_latest_importable_segment():
    """Empty latest tips should not make the whole conversation disappear."""
    conn = _ensure_state_db()
    ids_to_remove = ('empty_tip_root_001', 'empty_tip_001')
    t0 = time.time() - 500
    try:
        _insert_agent_session_row(
            conn,
            'empty_tip_root_001',
            title='Long Conversation',
            started_at=t0,
            ended_at=t0 + 100,
            end_reason='compression',
            messages=2,
        )
        _insert_agent_session_row(
            conn,
            'empty_tip_001',
            title='Long Conversation #2',
            started_at=t0 + 101,
            parent_session_id='empty_tip_root_001',
            messages=0,
        )

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s.get('session_id') for s in data.get('sessions', [])}

        assert 'empty_tip_root_001' in ids
        assert 'empty_tip_001' not in ids
        root = next((s for s in data.get('sessions', []) if s.get('session_id') == 'empty_tip_root_001'), None)
        assert root and root.get('title') == 'Long Conversation'
    finally:
        try:
            _remove_test_sessions(conn, *ids_to_remove)
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_compression_chain_with_all_empty_segments_is_hidden():
    """A compression chain with no importable segment should not appear."""
    conn = _ensure_state_db()
    ids_to_remove = ('all_empty_root_001', 'all_empty_tip_001')
    t0 = time.time() - 450
    try:
        _insert_agent_session_row(
            conn,
            'all_empty_root_001',
            title='Empty Long Conversation',
            started_at=t0,
            ended_at=t0 + 100,
            end_reason='compression',
            messages=0,
        )
        _insert_agent_session_row(
            conn,
            'all_empty_tip_001',
            title='Empty Long Conversation #2',
            started_at=t0 + 101,
            parent_session_id='all_empty_root_001',
            messages=0,
        )

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s.get('session_id') for s in data.get('sessions', [])}

        assert 'all_empty_root_001' not in ids
        assert 'all_empty_tip_001' not in ids
    finally:
        try:
            _remove_test_sessions(conn, *ids_to_remove)
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_default_title_cli_compression_chain_is_kept_by_lineage():
    """Default-titled CLI compression chains are meaningful even with a short tip."""
    conn = _ensure_state_db()
    ids_to_remove = ('cli_default_compress_root_001', 'cli_default_compress_tip_001')
    t0 = time.time() - 430
    try:
        _insert_agent_session_row(
            conn,
            'cli_default_compress_root_001',
            source='cli',
            title='Cli Session',
            started_at=t0,
            ended_at=t0 + 100,
            end_reason='compression',
            messages=1,
        )
        _insert_agent_session_row(
            conn,
            'cli_default_compress_tip_001',
            source='cli',
            title='Cli Session',
            started_at=t0 + 101,
            parent_session_id='cli_default_compress_root_001',
            messages=1,
        )

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s.get('session_id') for s in data.get('sessions', [])}

        assert 'cli_default_compress_tip_001' in ids
        assert 'cli_default_compress_root_001' not in ids
        tip = next((s for s in data.get('sessions', []) if s.get('session_id') == 'cli_default_compress_tip_001'), None)
        assert tip is not None, "compression tip session missing from /api/sessions (test-isolation? check show_cli_sessions + session presence)"
        assert tip.get('_compression_segment_count') == 2
        assert tip.get('_lineage_root_id') == 'cli_default_compress_root_001'
    finally:
        try:
            _remove_test_sessions(conn, *ids_to_remove)
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_non_compression_child_is_not_collapsed_into_parent():
    """Parent/child relationships that are not compression continuations stay flat."""
    conn = _ensure_state_db()
    ids_to_remove = ('branch_parent_001', 'branch_child_001')
    t0 = time.time() - 400
    try:
        _insert_agent_session_row(
            conn,
            'branch_parent_001',
            title='Branch Parent',
            started_at=t0,
            ended_at=t0 + 100,
            end_reason='branched',
            messages=2,
        )
        _insert_agent_session_row(
            conn,
            'branch_child_001',
            title='Branch Child',
            started_at=t0 + 101,
            parent_session_id='branch_parent_001',
            messages=2,
        )

        from api.agent_sessions import read_importable_agent_session_rows

        rows = read_importable_agent_session_rows(_get_state_db_path(), limit=None)
        ids = {row.get('id') for row in rows}

        assert 'branch_parent_001' in ids
        assert 'branch_child_001' in ids
    finally:
        try:
            _remove_test_sessions(conn, *ids_to_remove)
            conn.close()
        except Exception:
            pass


def test_agent_session_limit_applies_after_compression_projection():
    """A long raw chain should count as one logical sidebar row before limiting."""
    conn = _ensure_state_db()
    chain_ids = [f'limit_chain_{i:03d}' for i in range(8)]
    standalone_id = 'limit_standalone_001'
    t0 = time.time() - 300
    try:
        for i, sid in enumerate(chain_ids):
            _insert_agent_session_row(
                conn,
                sid,
                title=f'Limit Chain #{i + 1}',
                started_at=t0 + i,
                parent_session_id=chain_ids[i - 1] if i else None,
                ended_at=t0 + i + 0.5 if i < len(chain_ids) - 1 else None,
                end_reason='compression' if i < len(chain_ids) - 1 else None,
                messages=1,
            )
        _insert_agent_session_row(
            conn,
            standalone_id,
            title='Limit Standalone',
            started_at=t0 + 20,
            messages=1,
        )

        from api.agent_sessions import read_importable_agent_session_rows

        rows = read_importable_agent_session_rows(_get_state_db_path(), limit=2)
        ids = [row.get('id') for row in rows]

        assert len(rows) == 2
        assert chain_ids[-1] in ids
        assert standalone_id in ids
        assert not any(sid in ids for sid in chain_ids[:-1])
        chain = next((row for row in rows if row.get('id') == chain_ids[-1]), None)
        assert chain is not None, "limit-chain tip row missing from projected rows (test-isolation?)"
        assert chain.get('title') == 'Limit Chain #1'
        assert chain.get('_lineage_root_id') == chain_ids[0]
        assert chain.get('_compression_segment_count') == len(chain_ids)
    finally:
        try:
            _remove_test_sessions(conn, *(chain_ids + [standalone_id]))
            conn.close()
        except Exception:
            pass


def test_compression_chain_bubbles_to_top_by_tip_activity():
    """An actively-used compression chain must surface in the sidebar by its
    TIP's last activity, not by the (stale) root's last activity.

    Without overriding ``last_activity`` from the tip, a long-running chain
    whose tip is being actively edited NOW would sort by the root's old
    timestamp and fall below recently touched standalone sessions — the
    inverse of what users expect from "Show agent sessions" sorted by
    recency. This regression test pins the override.
    """
    conn = _ensure_state_db()
    ids_to_remove = ('bubble_root_001', 'bubble_tip_001', 'bubble_standalone_001')
    now = time.time()
    # Root started long ago; tip is being edited "now" (very recent message)
    root_started = now - 30 * 86400
    root_ended = now - 28 * 86400
    tip_started = root_ended + 1
    tip_latest_msg = now - 5  # 5 seconds ago — most recent activity in the DB
    # A standalone session active 2 days ago — older than tip, much newer
    # than the root. Without the fix, the chain row sorts by ROOT's age and
    # standalone wins; with the fix, the chain wins.
    standalone_msg = now - 2 * 86400
    try:
        _insert_agent_session_row(
            conn,
            'bubble_root_001',
            title='Bubble Root',
            started_at=root_started,
            ended_at=root_ended,
            end_reason='compression',
            messages=2,
        )
        # Override message timestamps so root's last_activity is genuinely old.
        conn.execute("DELETE FROM messages WHERE session_id = 'bubble_root_001'")
        conn.execute(
            "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
            ('bubble_root_001', 'user', 'old root msg', root_started + 60),
        )
        _insert_agent_session_row(
            conn,
            'bubble_tip_001',
            title='Bubble Tip',
            started_at=tip_started,
            parent_session_id='bubble_root_001',
            messages=1,
        )
        conn.execute("DELETE FROM messages WHERE session_id = 'bubble_tip_001'")
        conn.execute(
            "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
            ('bubble_tip_001', 'user', 'fresh tip msg', tip_latest_msg),
        )
        _insert_agent_session_row(
            conn,
            'bubble_standalone_001',
            title='Bubble Standalone',
            started_at=now - 2 * 86400 - 60,
            messages=1,
        )
        conn.execute("DELETE FROM messages WHERE session_id = 'bubble_standalone_001'")
        conn.execute(
            "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
            ('bubble_standalone_001', 'user', 'standalone msg', standalone_msg),
        )
        conn.commit()

        from api.agent_sessions import read_importable_agent_session_rows

        rows = read_importable_agent_session_rows(_get_state_db_path(), limit=200)
        ids = [row.get('id') for row in rows]
        # Filter out unrelated rows from the shared DB
        ids = [i for i in ids if i in ('bubble_root_001', 'bubble_tip_001', 'bubble_standalone_001')]

        assert 'bubble_tip_001' in ids, (
            f"Compression tip must appear in projected output. ids={ids}"
        )
        assert 'bubble_root_001' not in ids, (
            "Compression root row must be hidden once the tip is the active row."
        )

        tip_pos = ids.index('bubble_tip_001')
        standalone_pos = ids.index('bubble_standalone_001') if 'bubble_standalone_001' in ids else -1
        assert standalone_pos == -1 or tip_pos < standalone_pos, (
            f"Active compression tip (last msg 5s ago) must sort BEFORE standalone "
            f"session (last msg 2d ago). Got order: {ids}. "
            f"This indicates merged.last_activity is the root's stale value, "
            f"not the tip's recent value."
        )

        tip_row = next((r for r in rows if r['id'] == 'bubble_tip_001'), None)
        assert tip_row is not None, "bubble tip row missing from projected rows (test-isolation?)"
        assert abs(tip_row['last_activity'] - tip_latest_msg) < 0.01, (
            f"Projected tip's last_activity must equal the tip's most recent "
            f"message timestamp ({tip_latest_msg}), not the root's "
            f"({root_started + 60}). Got: {tip_row['last_activity']}"
        )
    finally:
        try:
            _remove_test_sessions(conn, *ids_to_remove)
            conn.close()
        except Exception:
            pass


def test_gateway_sessions_excluded_when_disabled():
    """Gateway sessions are NOT returned when show_cli_sessions is off."""
    conn = _ensure_state_db()
    try:
        _insert_gateway_session(conn, session_id='gw_test_dc_001', source='discord', title='DC Test Chat')

        # Ensure setting is off
        post('/api/settings', {'show_cli_sessions': False})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_dc_001']
        assert len(gw_ids) == 0, "Gateway session should not appear when setting is off"
    finally:
        try:
            _remove_test_sessions(conn, 'gw_test_dc_001')
            conn.close()
        except Exception:
            pass


def test_gateway_session_has_correct_metadata():
    """Gateway sessions include legacy source fields and normalized source metadata."""
    conn = _ensure_state_db()
    try:
        _insert_gateway_session(conn, session_id='gw_meta_001', source='telegram', title='Meta Test')

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        gw = next((s for s in sessions if s['session_id'] == 'gw_meta_001'), None)
        assert gw is not None, "Gateway session not found"
        assert gw.get('source_tag') == 'telegram', f"Expected source_tag=telegram, got {gw.get('source_tag')}"
        assert gw.get('raw_source') == 'telegram'
        assert gw.get('session_source') == 'messaging'
        assert gw.get('source_label') == 'Telegram'
        assert gw.get('is_cli_session') is False, "is_cli_session should be False for messaging (telegram) sessions"
        assert gw.get('title') == 'Meta Test'
    finally:
        try:
            _remove_test_sessions(conn, 'gw_meta_001')
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_agent_session_source_normalization_contract():
    """Raw Hermes Agent sources map to stable WebUI source categories."""
    from api.agent_sessions import normalize_agent_session_source

    cases = {
        'cli': ('cli', 'CLI'),
        'email': ('messaging', 'Email'),
        'wecom': ('messaging', 'WeCom'),
        'wecom_callback': ('messaging', 'WeCom Callback'),
        'weixin': ('messaging', 'Weixin'),
        'telegram': ('messaging', 'Telegram'),
        'discord': ('messaging', 'Discord'),
        'slack': ('messaging', 'Slack'),
        'matrix': ('messaging', 'Matrix'),
        'signal': ('messaging', 'Signal'),
        'cron': ('cron', 'Cron'),
        'webhook': ('webhook', 'Webhook'),
        'tool': ('tool', 'Tool'),
        'api_server': ('api', 'API'),
        'something_new': ('other', 'Something New'),
        None: ('other', 'Agent'),
    }

    for raw_source, (session_source, source_label) in cases.items():
        normalized = normalize_agent_session_source(raw_source)
        assert normalized['session_source'] == session_source
        assert normalized['source_label'] == source_label
        if raw_source:
            assert normalized['raw_source'] == raw_source
        else:
            assert normalized['raw_source'] is None


def test_sessions_js_treats_email_as_messaging_source():
    """Email gateway sessions should receive the same sidebar metadata as other messaging channels."""
    src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")

    raw_section = src[src.find("_MESSAGING_RAW_SOURCES"):src.find("function _isMessagingSession")]
    label_section = src[src.find("_MESSAGING_SOURCE_LABELS"):src.find("function _isMessagingSession")]

    for raw_source in ("email", "wecom", "wecom_callback", "matrix", "signal"):
        assert f"'{raw_source}'" in raw_section, f"Missing raw source {raw_source!r} in _MESSAGING_RAW_SOURCES"

    assert "email: 'Email'" in label_section
    assert "wecom: 'WeCom'" in label_section
    assert "wecom_callback: 'WeCom Callback'" in label_section
    assert "matrix: 'Matrix'" in label_section
    assert "signal: 'Signal'" in label_section


def test_sessions_js_treats_messaging_sidecars_behaviorally():
    """Stale messaging sidecars with session_source=other should still route as messaging."""
    src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
    start = src.index("const _MESSAGING_RAW_SOURCES")
    end = src.index("/**", start)
    block = src[start:end]
    script = f"""
{block}
const cases = [
  {{ session_source: 'other', source: 'wecom' }},
  {{ session_source: 'other', raw_source: 'wecom_callback' }},
  {{ session_source: 'other', source_tag: 'wecom' }},
  {{ session_source: 'other', source: 'matrix' }},
  {{ session_source: 'other', source: 'signal' }},
  {{ session_source: 'other', raw_source: 'signal' }},
  {{ session_source: 'messaging', source: 'anything' }},
];
for (const c of cases) {{
  if (!_isMessagingSession(c)) throw new Error('expected messaging for '+JSON.stringify(c));
}}
if (_isMessagingSession({{ session_source: 'other', source: 'cli' }})) {{
  throw new Error('cli should not be treated as messaging');
}}
"""
    subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)


def test_sessions_js_treats_signal_sidecar_as_external_session():
    """A Signal gateway session must be exposed as an external session.

    The sidebar only lists sessions the client classifier reports as external,
    and _isExternalSession routes messaging sources through _isMessagingSession.
    A Signal sidecar whose persisted session_source is the legacy 'other' value
    therefore has to be recognized from its raw source, exactly like the Matrix
    precedent (#6816). This exercises the real sessions.js classifiers in node
    rather than asserting on the shape of the allowlists.
    """
    src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
    start = src.index("const _MESSAGING_RAW_SOURCES")
    end = src.index("\n}", src.index("function _isExternalSession")) + len("\n}")
    block = src[start:end]
    script = f"""
{block}
if (!_isMessagingSession({{ session_source: 'other', source: 'signal' }})) {{
  throw new Error('signal session was not classified as messaging');
}}
if (_MESSAGING_SOURCE_LABELS['signal'] !== 'Signal') {{
  throw new Error('unexpected signal source label: ' + _MESSAGING_SOURCE_LABELS['signal']);
}}
for (const c of [
  {{ session_source: 'other', source: 'signal' }},
  {{ session_source: 'other', raw_source: 'signal' }},
  {{ session_source: 'other', source_tag: 'signal' }},
]) {{
  if (!_isExternalSession(c)) {{
    throw new Error('signal sidecar is not exposed as an external session: ' + JSON.stringify(c));
  }}
}}
if (_isExternalSession({{ session_source: 'webui', source: 'signal' }})) {{
  throw new Error('a WebUI-origin session must not be treated as an external session');
}}
if (_isExternalSession({{ session_source: 'other', source: 'some_future_platform' }})) {{
  throw new Error('an unrecognized source must not be treated as an external session');
}}
"""
    subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)


def test_empty_active_gateway_session_does_not_hide_messaging_history(monkeypatch):
    """A zero-message active Gateway row must not hide older Discord history."""
    import api.routes as routes

    monkeypatch.setattr(
        routes,
        "_load_gateway_session_identity_map",
        lambda: {
            "discord_empty_active": {
                "raw_source": "discord",
                "platform": "discord",
                "user_id": "user-1",
            }
        },
    )

    rows = [
        {
            "session_id": "discord_previous_history",
            "title": "Previous Discord chat",
            "source_tag": "discord",
            "raw_source": "discord",
            "session_source": "messaging",
            "source_label": "Discord",
            "user_id": "user-1",
            "message_count": 7,
            "updated_at": 100.0,
            "end_reason": "session_reset",
        }
    ]

    kept = routes._keep_latest_messaging_session_per_source(rows)

    assert [row["session_id"] for row in kept] == ["discord_previous_history"]


def test_previous_messaging_setting_keeps_reset_history(monkeypatch):
    """The previous-messaging toggle exposes older reset segments."""
    import api.routes as routes

    monkeypatch.setattr(
        routes,
        "_load_gateway_session_identity_map",
        lambda: {
            "discord_active": {
                "raw_source": "discord",
                "platform": "discord",
                "user_id": "user-1",
            }
        },
    )

    rows = [
        {
            "session_id": "discord_active",
            "title": "Current Discord chat",
            "source_tag": "discord",
            "raw_source": "discord",
            "session_source": "messaging",
            "source_label": "Discord",
            "user_id": "user-1",
            "message_count": 3,
            "updated_at": 200.0,
        },
        {
            "session_id": "discord_previous_history",
            "title": "Previous Discord chat",
            "source_tag": "discord",
            "raw_source": "discord",
            "session_source": "messaging",
            "source_label": "Discord",
            "user_id": "user-1",
            "message_count": 7,
            "updated_at": 100.0,
            "end_reason": "session_reset",
        },
    ]

    hidden = routes._keep_latest_messaging_session_per_source(rows)
    visible = routes._keep_latest_messaging_session_per_source(
        rows,
        show_previous_messaging_sessions=True,
    )

    assert [row["session_id"] for row in hidden] == ["discord_active"]
    assert [row["session_id"] for row in visible] == [
        "discord_active",
        "discord_previous_history",
    ]


def test_cross_source_parent_child_is_not_collapsed_into_root_metadata(cleanup_test_sessions):
    """A WebUI continuation from a messaging parent must keep WebUI metadata.

    Regression for a production case where a WebUI session continued from a
    Telegram compression chain and was projected as the old Telegram root,
    inheriting the wrong title/source and hiding from the expected sidebar view.
    """
    from api.agent_sessions import read_importable_agent_session_rows

    conn = _ensure_state_db()
    root_sid = 'gw_tg_cross_source_root_001'
    webui_sid = 'webui_cross_source_tip_001'
    now = time.time()
    cleanup_test_sessions.extend([root_sid, webui_sid])
    try:
        _insert_agent_session_row(
            conn,
            session_id=root_sid,
            source='telegram',
            title='Old Telegram Root',
            started_at=now - 20,
            ended_at=now - 10,
            end_reason='compression',
            messages=2,
        )
        _insert_agent_session_row(
            conn,
            session_id=webui_sid,
            source='webui',
            title='Current WebUI Work',
            started_at=now - 9,
            parent_session_id=root_sid,
            messages=2,
        )

        rows = read_importable_agent_session_rows(_get_state_db_path(), exclude_sources=None)
        by_id = {row['id']: row for row in rows}

        assert webui_sid in by_id
        assert root_sid in by_id
        webui = by_id[webui_sid]
        assert webui.get('title') == 'Current WebUI Work'
        assert webui.get('source') == 'webui'
        assert webui.get('session_source') == 'webui'
        assert webui.get('source_label') == 'WebUI'
        assert webui.get('relationship_type') == 'child_session'
        assert webui.get('parent_title') == 'Old Telegram Root'
    finally:
        try:
            _remove_test_sessions(conn, root_sid, webui_sid)
            conn.close()
        except Exception:
            pass


def test_gateway_watcher_uses_normalized_source_metadata(monkeypatch):
    """SSE snapshots use the same normalized source contract as /api/sessions."""
    conn = _ensure_state_db()
    try:
        _insert_gateway_session(conn, session_id='gw_watcher_source_001', source='weixin', title='Weixin Chat')

        import api.gateway_watcher as gateway_watcher

        monkeypatch.setattr(gateway_watcher, '_get_state_db_path', _get_state_db_path)
        sessions = gateway_watcher._get_agent_sessions_from_db()
        gw = next((s for s in sessions if s['session_id'] == 'gw_watcher_source_001'), None)

        assert gw is not None
        assert gw.get('source') == 'weixin'
        assert gw.get('raw_source') == 'weixin'
        assert gw.get('session_source') == 'messaging'
        assert gw.get('source_label') == 'Weixin'
    finally:
        try:
            _remove_test_sessions(conn, 'gw_watcher_source_001')
            conn.close()
        except Exception:
            pass


def test_imported_cli_session_metadata_survives_compact(cleanup_test_sessions):
    """Imported agent sessions should remain distinguishable in compact sidebar payloads."""
    from api.models import Session

    sid = 'gw_imported_metadata_001'
    cleanup_test_sessions.append(sid)
    s = Session(
        session_id=sid,
        title='Imported Telegram Chat',
        messages=[{'role': 'user', 'content': 'hello from telegram', 'timestamp': time.time()}],
        model='openai/gpt-5',
    )
    s.is_cli_session = True
    s.source_tag = 'telegram'
    s.session_source = 'messaging'
    s.source_label = 'Telegram'
    s.save(touch_updated_at=False)

    loaded = Session.load_metadata_only(sid)
    compact = loaded.compact()

    assert compact['is_cli_session'] is True
    assert compact['source_tag'] == 'telegram'
    assert compact['session_source'] == 'messaging'
    assert compact['source_label'] == 'Telegram'


def test_import_cli_preserves_messaging_source_metadata(cleanup_test_sessions):
    """Importing a messaging agent session should keep source metadata for WebUI policy."""
    conn = _ensure_state_db()
    sid = 'gw_import_weixin_meta_001'
    cleanup_test_sessions.append(sid)
    try:
        _insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session')

        data, status = post('/api/session/import_cli', {'session_id': sid})
        assert status == 200
        session = data.get('session', {})
        assert session.get('is_cli_session') is True
        assert session.get('source_tag') == 'weixin'
        assert session.get('raw_source') == 'weixin'
        assert session.get('session_source') == 'messaging'
        assert session.get('source_label') == 'Weixin'
    finally:
        try:
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass


def test_import_cli_named_profile_requires_explicit_all_profiles_opt_in(cleanup_test_sessions):
    """Unqualified import_cli should not read a named profile's state.db."""
    named_profile = 'issue1611-import'
    conn = _ensure_state_db(profile=named_profile)
    sid = 'gw_named_profile_import_001'
    cleanup_test_sessions.append(sid)
    try:
        _insert_gateway_session(
            conn,
            session_id=sid,
            source='telegram',
            title='Named Profile Telegram Session',
        )

        data, status = post('/api/session/import_cli', {'session_id': sid})
        assert status == 404, data
    finally:
        try:
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass


def test_import_cli_all_profiles_opt_in_reads_named_profile_state_db(cleanup_test_sessions):
    """Explicit all-profiles imports should resolve messages from the named profile store."""
    named_profile = 'issue1611-import'
    conn = _ensure_state_db(profile=named_profile)
    sid = 'gw_named_profile_import_001'
    cleanup_test_sessions.append(sid)
    try:
        _insert_gateway_session(
            conn,
            session_id=sid,
            source='telegram',
            title='Named Profile Telegram Session',
        )

        data, status = post('/api/session/import_cli', {
            'session_id': sid,
            'all_profiles': True,
            'profile': named_profile,
        })
        assert status == 200, data
        session = data.get('session', {})
        messages = session.get('messages', [])

        assert session.get('session_id') == sid
        assert session.get('profile') == named_profile
        assert session.get('source_tag') == 'telegram'
        assert session.get('session_source') == 'messaging'
        assert [m.get('content') for m in messages] == ['Hello from Telegram', 'Hi there!']
    finally:
        try:
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass


def test_all_profiles_cli_contexts_normalizes_default_profile_name(tmp_path, monkeypatch):
    from api import models, profiles

    profiles_root = tmp_path / "profiles"
    profiles_root.mkdir()
    (profiles_root / "research").mkdir()

    monkeypatch.setattr(profiles, "_profiles_root", lambda: profiles_root)
    monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None)
    monkeypatch.setattr(
        profiles,
        "get_hermes_home_for_profile",
        lambda profile_name: tmp_path / (profile_name or "default-home"),
    )
    monkeypatch.setattr(
        profiles,
        "list_profiles_api",
        lambda: [{"name": None}, {"name": "research"}],
    )

    contexts, _cache_key = models._all_profiles_cli_contexts()

    assert ("default" in [profile for _home, _db_path, profile in contexts])
    assert ("research" in [profile for _home, _db_path, profile in contexts])


def test_sessions_response_backfills_imported_messaging_source_metadata(cleanup_test_sessions):
    """Old imported messaging sessions should still expose source metadata in /api/sessions."""
    from api.models import Session

    conn = _ensure_state_db()
    sid = 'gw_legacy_import_weixin_001'
    cleanup_test_sessions.append(sid)
    try:
        _insert_gateway_session(
            conn,
            session_id=sid,
            source='weixin',
            title='Weixin Session',
            session_key='agent:test:weixin:legacy-import-backfill',
        )
        s = Session(
            session_id=sid,
            title='Legacy Imported Weixin',
            messages=[{'role': 'user', 'content': 'hello', 'timestamp': time.time()}],
            model='openai/gpt-5',
        )
        s.is_cli_session = True
        s.save(touch_updated_at=False)
        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        session = next((item for item in data.get('sessions', []) if item.get('session_id') == sid), None)
        assert session is not None, f"{sid} missing from /api/sessions — show_cli_sessions not applied or session hidden (test-isolation)"
        assert session.get('source_tag') == 'weixin'
        assert session.get('raw_source') == 'weixin'
        assert session.get('session_source') == 'messaging'
        assert session.get('source_label') == 'Weixin'
    finally:
        try:
            post('/api/settings', {'show_cli_sessions': False})
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass


def test_sessions_response_keeps_only_latest_messaging_session_per_source(cleanup_test_sessions):
    """Sidebar should keep messaging sessions by stable identity, not source-wide."""
    from api.models import Session

    conn = _ensure_state_db()
    old_sid = 'gw_old_weixin_visible_001'
    new_sid = 'gw_new_weixin_visible_001'
    cleanup_test_sessions.extend([old_sid, new_sid])
    try:
        _insert_gateway_session(conn, session_id=old_sid, source='weixin', title='Old Weixin', started_at=time.time() - 100)
        _insert_gateway_session(conn, session_id=new_sid, source='weixin', title='New Weixin', started_at=time.time())

        old = Session(
            session_id=old_sid,
            title='Old Imported Weixin',
            messages=[{'role': 'user', 'content': 'old', 'timestamp': time.time() - 100}],
            model='openai/gpt-5',
        )
        old.is_cli_session = True
        old.save(touch_updated_at=False)
        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        ids = {item.get('session_id') for item in data.get('sessions', [])}
        assert new_sid in ids
        assert old_sid not in ids
    finally:
        try:
            post('/api/settings', {'show_cli_sessions': False})
            _remove_test_sessions(conn, old_sid, new_sid)
            conn.close()
        except Exception:
            pass


def test_sessions_response_keeps_distinct_messaging_sessions_for_distinct_users(cleanup_test_sessions):
    """Messaging collapse should survive for different users on the same platform."""
    conn = _ensure_state_db()
    sid_a = 'gw_tg_distinct_user_a'
    sid_b = 'gw_tg_distinct_user_b'
    cleanup_test_sessions.extend([sid_a, sid_b])
    try:
        _insert_gateway_session(
            conn,
            session_id=sid_a,
            source='telegram',
            title='TG User A',
            user_id='1143399746',
            started_at=time.time() - 20,
        )
        _insert_gateway_session(
            conn,
            session_id=sid_b,
            source='telegram',
            title='TG User B',
            user_id='9988776655',
            started_at=time.time(),
        )

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s['session_id'] for s in data.get('sessions', []) if s.get('session_id') in {sid_a, sid_b}}
        assert ids == {sid_a, sid_b}, f"Expected both Telegram sessions to remain, got {ids}"
    finally:
        try:
            post('/api/settings', {'show_cli_sessions': False})
            _remove_test_sessions(conn, sid_a, sid_b)
            conn.close()
        except Exception:
            pass


def test_sessions_response_distinguishes_same_user_different_chat_identity_from_gateway_metadata(cleanup_test_sessions):
    """Same user_id sessions should stay separate when gateway metadata exposes chat identity."""
    conn = _ensure_state_db()
    sid_dm = 'gw_tg_same_user_dm'
    sid_group = 'gw_tg_same_user_group'
    cleanup_test_sessions.extend([sid_dm, sid_group])
    sessions_file = _get_test_state_dir() / 'sessions' / 'sessions.json'
    original_sessions_json = None
    if sessions_file.exists():
        original_sessions_json = sessions_file.read_text()
    sessions_file.parent.mkdir(parents=True, exist_ok=True)
    sessions_payload = {
        "agent:main:telegram:dm:1143399746": {
            "session_key": "agent:main:telegram:dm:1143399746",
            "session_id": sid_dm,
            "origin": {
                "platform": "telegram",
                "chat_type": "dm",
                "chat_id": "1143399746",
                "user_id": "1143399746",
            },
        },
        "agent:main:telegram:group:chat_42:1143399746": {
            "session_key": "agent:main:telegram:group:chat_42:1143399746",
            "session_id": sid_group,
            "origin": {
                "platform": "telegram",
                "chat_type": "group",
                "chat_id": "chat_42",
                "user_id": "1143399746",
            },
        },
    }
    try:
        sessions_file.write_text(json.dumps(sessions_payload), encoding='utf-8')
        _insert_gateway_session(conn, session_id=sid_dm, source='telegram', title='DM Same User', user_id='1143399746', started_at=time.time() - 40)
        _insert_gateway_session(conn, session_id=sid_group, source='telegram', title='Group Same User', user_id='1143399746', started_at=time.time())

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s['session_id'] for s in data.get('sessions', []) if s.get('session_id') in {sid_dm, sid_group}}
        assert ids == {sid_dm, sid_group}, f"Expected both DM/group Telegram sessions, got {ids}"
    finally:
        try:
            post('/api/settings', {'show_cli_sessions': False})
            _remove_test_sessions(conn, sid_dm, sid_group)
            if original_sessions_json is None:
                sessions_file.unlink(missing_ok=True)
            else:
                sessions_file.write_text(original_sessions_json, encoding='utf-8')
            conn.close()
        except Exception:
            pass


def test_messaging_projection_keeps_no_identity_continuation_when_gateway_source_active(monkeypatch, cleanup_test_sessions):
    """A WebUI/mobile recovery continuation must not disappear behind source-wide Gateway hiding.

    Long WebUI turns can rotate to a child session during compression. If the
    browser is backgrounded before the final SSE handoff, recovery depends on
    the continuation still being visible in the sidebar. This is source-generic:
    Telegram, Discord, Slack, and Weixin all use the same messaging projection.
    """
    from api import routes
    from api.models import Session

    parent_sid = "webui_pre_compression_snapshot"
    cleanup_test_sessions.append(parent_sid)
    Session(
        session_id=parent_sid,
        title="Archived pre-compression snapshot",
        model="openai/gpt-5",
        messages=[{"role": "user", "content": "before compression", "timestamp": time.time() - 10}],
        pre_compression_snapshot=True,
    ).save(touch_updated_at=False)

    for source in ("telegram", "discord", "slack", "weixin"):
        active_sid = f"{source}_active_sid"
        continuation_sid = f"webui_{source}_continuation_no_identity"
        cleanup_test_sessions.append(continuation_sid)
        monkeypatch.setattr(
            routes,
            "_load_gateway_session_identity_map",
            lambda source=source, active_sid=active_sid: {
                active_sid: {
                    "session_key": f"agent:main:{source}:dm:user_1",
                    "raw_source": source,
                    "platform": source,
                    "chat_type": "dm",
                    "chat_id": "user_1",
                    "user_id": "user_1",
                },
            },
        )
        sessions = [
            {
                "session_id": active_sid,
                "raw_source": source,
                "session_source": "messaging",
                "title": f"Current {source} DM",
                "updated_at": 100,
                "message_count": 3,
            },
            {
                "session_id": continuation_sid,
                "raw_source": source,
                "session_source": "messaging",
                "chat_id": "webui_recovery_chat",
                "chat_type": "dm",
                "title": f"Recovered {source} WebUI continuation",
                "parent_session_id": parent_sid,
                "updated_at": 200,
                "message_count": 1003,
            },
        ]

        kept = routes._keep_latest_messaging_session_per_source(sessions)
        ids = {session.get("session_id") for session in kept}

        assert ids == {active_sid, continuation_sid}


def test_session_load_exposes_continuation_for_pre_compression_snapshot(cleanup_test_sessions):
    """Reloading an old hidden compression snapshot should give the browser a recovery target."""
    from api.models import Session

    parent_sid = "webui_mobile_snapshot_parent"
    child_sid = "webui_mobile_snapshot_child"
    now = time.time()
    cleanup_test_sessions.extend([parent_sid, child_sid])

    parent = Session(
        session_id=parent_sid,
        title="Mobile reload parent",
        model="openai/gpt-5",
        messages=[{"role": "user", "content": "before compression", "timestamp": now - 10}],
        created_at=now - 20,
        updated_at=now - 10,
        pre_compression_snapshot=True,
    )
    child = Session(
        session_id=child_sid,
        title="Mobile reload child",
        model="openai/gpt-5",
        parent_session_id=parent_sid,
        messages=[{"role": "assistant", "content": "finished after reload", "timestamp": now}],
        created_at=now - 5,
        updated_at=now,
    )
    parent.save(touch_updated_at=False)
    child.save(touch_updated_at=False)

    data, status = get(f"/api/session?session_id={parent_sid}&messages=0&resolve_model=0")

    assert status == 200
    assert data["session"].get("session_id") == parent_sid
    assert data["session"].get("continuation_session_id") == child_sid


def test_session_load_exposes_multihop_continuation_for_repeated_compression(cleanup_test_sessions):
    """A stale older snapshot should recover to the newest visible descendant."""
    from api.models import Session

    root_sid = "webui_mobile_snapshot_root"
    middle_sid = "webui_mobile_snapshot_middle"
    child_sid = "webui_mobile_snapshot_grandchild"
    now = time.time()
    cleanup_test_sessions.extend([root_sid, middle_sid, child_sid])

    root = Session(
        session_id=root_sid,
        title="Mobile reload root snapshot",
        model="openai/gpt-5",
        messages=[{"role": "user", "content": "before first compression", "timestamp": now - 30}],
        created_at=now - 40,
        updated_at=now - 30,
        pre_compression_snapshot=True,
    )
    middle = Session(
        session_id=middle_sid,
        title="Mobile reload middle snapshot",
        model="openai/gpt-5",
        parent_session_id=root_sid,
        messages=[{"role": "assistant", "content": "before second compression", "timestamp": now - 20}],
        created_at=now - 25,
        updated_at=now - 20,
        pre_compression_snapshot=True,
    )
    child = Session(
        session_id=child_sid,
        title="Mobile reload final child",
        model="openai/gpt-5",
        parent_session_id=middle_sid,
        messages=[{"role": "assistant", "content": "finished after repeated compression", "timestamp": now}],
        created_at=now - 5,
        updated_at=now,
    )
    root.save(touch_updated_at=False)
    middle.save(touch_updated_at=False)
    child.save(touch_updated_at=False)

    data, status = get(f"/api/session?session_id={root_sid}&messages=0&resolve_model=0")

    assert status == 200
    assert data["session"].get("session_id") == root_sid
    assert data["session"].get("continuation_session_id") == child_sid


def test_messaging_projection_hides_stale_gateway_internal_segments(monkeypatch):
    """Active Gateway identity should hide old reset rows and internal child segments."""
    from api import routes

    monkeypatch.setattr(
        routes,
        "_load_gateway_session_identity_map",
        lambda: {
            "weixin_current_sid": {
                "session_key": "agent:main:weixin:dm:user_1",
                "raw_source": "weixin",
                "platform": "weixin",
                "chat_type": "dm",
                "chat_id": "user_1",
                "user_id": "user_1",
            },
        },
    )
    sessions = [
        {
            "session_id": "weixin_current_sid",
            "raw_source": "weixin",
            "title": "Current Weixin",
            "updated_at": 100,
            "message_count": 8,
        },
        {
            "session_id": "weixin_internal_child_sid",
            "raw_source": "weixin",
            "title": "Internal Weixin Segment",
            "parent_session_id": "weixin_current_sid",
            "updated_at": 120,
            "message_count": 4,
        },
        {
            "session_id": "weixin_reset_sid",
            "raw_source": "weixin",
            "title": "Old Weixin Reset",
            "end_reason": "session_reset",
            "updated_at": 90,
            "message_count": 6,
        },
        {
            "session_id": "weixin_legacy_fallback_sid",
            "raw_source": "weixin",
            "title": "Legacy Weixin Fallback",
            "updated_at": 95,
            "message_count": 3,
            "user_id": "user_1",
        },
        {
            "session_id": "webui_sid",
            "title": "Regular WebUI",
            "updated_at": 80,
            "message_count": 2,
        },
    ]

    kept = routes._keep_latest_messaging_session_per_source(sessions)
    ids = {session.get("session_id") for session in kept}

    assert "weixin_current_sid" in ids
    assert "webui_sid" in ids
    assert "weixin_internal_child_sid" not in ids
    assert "weixin_reset_sid" not in ids
    assert "weixin_legacy_fallback_sid" not in ids


def test_messaging_projection_keeps_distinct_active_gateway_conversations(monkeypatch):
    """Telegram DM and group chats must not collapse just because source matches."""
    from api import routes

    monkeypatch.setattr(
        routes,
        "_load_gateway_session_identity_map",
        lambda: {
            "telegram_dm_sid": {
                "session_key": "agent:main:telegram:dm:user_1",
                "raw_source": "telegram",
                "platform": "telegram",
                "chat_type": "dm",
                "chat_id": "user_1",
                "user_id": "user_1",
            },
            "telegram_group_sid": {
                "session_key": "agent:main:telegram:group:group_1:user_1",
                "raw_source": "telegram",
                "platform": "telegram",
                "chat_type": "group",
                "chat_id": "group_1",
                "user_id": "user_1",
            },
        },
    )
    sessions = [
        {
            "session_id": "telegram_dm_sid",
            "raw_source": "telegram",
            "title": "Telegram DM",
            "updated_at": 100,
            "message_count": 4,
        },
        {
            "session_id": "telegram_group_sid",
            "raw_source": "telegram",
            "title": "Telegram Group",
            "updated_at": 110,
            "message_count": 4,
        },
    ]

    kept = routes._keep_latest_messaging_session_per_source(sessions)
    ids = {session.get("session_id") for session in kept}

    assert ids == {"telegram_dm_sid", "telegram_group_sid"}


def test_messaging_projection_does_not_aggressively_hide_without_gateway_metadata(monkeypatch):
    """Without sessions.json as source of truth, keep fallback behavior."""
    from api import routes

    monkeypatch.setattr(routes, "_load_gateway_session_identity_map", lambda: {})
    sessions = [
        {
            "session_id": "weixin_reset_sid",
            "raw_source": "weixin",
            "title": "Old Weixin Reset",
            "end_reason": "session_reset",
            "updated_at": 90,
            "message_count": 6,
        },
    ]

    kept = routes._keep_latest_messaging_session_per_source(sessions)

    assert [session.get("session_id") for session in kept] == ["weixin_reset_sid"]


def test_sessions_response_distinguishes_same_platform_same_group_chat_different_users_without_session_key(cleanup_test_sessions):
    """Group sessions with same chat_id but different users should not collapse without session_key."""
    conn = _ensure_state_db()
    sid_u1 = 'gw_tg_group_chat_001'
    sid_u2 = 'gw_tg_group_chat_002'
    cleanup_test_sessions.extend([sid_u1, sid_u2])
    try:
        _insert_gateway_session(
            conn,
            session_id=sid_u1,
            source='telegram',
            title='TG Group Same Chat User1',
            user_id='2001001',
            chat_id='tg_group_42',
            chat_type='group',
            started_at=time.time() - 20,
        )
        _insert_gateway_session(
            conn,
            session_id=sid_u2,
            source='telegram',
            title='TG Group Same Chat User2',
            user_id='2001002',
            chat_id='tg_group_42',
            chat_type='group',
            started_at=time.time(),
        )

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s['session_id'] for s in data.get('sessions', []) if s.get('session_id') in {sid_u1, sid_u2}}
        assert ids == {sid_u1, sid_u2}, (
            f"Expected both group sessions in same chat to stay visible without session_key, got {ids}"
        )
    finally:
        try:
            post('/api/settings', {'show_cli_sessions': False})
            _remove_test_sessions(conn, sid_u1, sid_u2)
            conn.close()
        except Exception:
            pass


def test_sessions_response_distinguishes_same_user_different_thread_without_session_key(cleanup_test_sessions):
    """Same user_id but different thread context should remain separate without session_key."""
    conn = _ensure_state_db()
    sid_t1 = 'gw_tg_thread_001'
    sid_t2 = 'gw_tg_thread_002'
    cleanup_test_sessions.extend([sid_t1, sid_t2])
    try:
        _insert_gateway_session(
            conn,
            session_id=sid_t1,
            source='telegram',
            title='TG Thread A',
            user_id='5550007',
            chat_id='tg_group_42',
            chat_type='thread',
            thread_id='thread_a',
            started_at=time.time() - 20,
        )
        _insert_gateway_session(
            conn,
            session_id=sid_t2,
            source='telegram',
            title='TG Thread B',
            user_id='5550007',
            chat_id='tg_group_42',
            chat_type='thread',
            thread_id='thread_b',
            started_at=time.time(),
        )

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s['session_id'] for s in data.get('sessions', []) if s.get('session_id') in {sid_t1, sid_t2}}
        assert ids == {sid_t1, sid_t2}, (
            f"Expected both thread-scoped Telegram sessions to stay visible without session_key, got {ids}"
        )
    finally:
        try:
            post('/api/settings', {'show_cli_sessions': False})
            _remove_test_sessions(conn, sid_t1, sid_t2)
            conn.close()
        except Exception:
            pass


def test_archiving_raw_messaging_session_imports_without_erasing_agent_memory(cleanup_test_sessions):
    """Archive should be the safe hide path for raw messaging sessions."""
    conn = _ensure_state_db()
    sid = 'gw_archive_weixin_001'
    cleanup_test_sessions.append(sid)
    try:
        _insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session')

        data, status = post('/api/session/archive', {'session_id': sid, 'archived': True})
        assert status == 200
        session = data.get('session', {})
        assert session.get('archived') is True
        assert session.get('session_source') == 'messaging'

        remaining = conn.execute(
            "SELECT COUNT(*) FROM messages WHERE session_id = ?",
            (sid,),
        ).fetchone()[0]
        assert remaining == 2
    finally:
        try:
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass


def test_delete_imported_messaging_session_preserves_agent_memory(cleanup_test_sessions):
    """WebUI delete must not delete Hermes Agent memory for external channels."""
    conn = _ensure_state_db()
    sid = 'gw_delete_weixin_safe_001'
    cleanup_test_sessions.append(sid)
    try:
        _insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session')
        _, import_status = post('/api/session/import_cli', {'session_id': sid})
        assert import_status == 200

        _, delete_status = post('/api/session/delete', {'session_id': sid})
        assert delete_status == 200

        remaining = conn.execute(
            "SELECT COUNT(*) FROM messages WHERE session_id = ?",
            (sid,),
        ).fetchone()[0]
        assert remaining == 2

        # A messaging-session delete deliberately preserves the state.db
        # transcript (delete_cli_session is skipped), so it must NOT be
        # recorded in the deleted-WebUI tombstone — otherwise
        # _claim_or_synthesize_cli_session() would treat the still-live
        # channel session as was-webui and self-heal it to a 404 on reopen.
        import api.models as _m
        assert sid not in _m._load_webui_deleted_session_tombstone(), (
            "messaging session must not be tombstoned as a deleted WebUI session"
        )
    finally:
        try:
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass


def test_deleted_webui_session_stays_out_of_sidebar_projection(cleanup_test_sessions):
    """A tombstoned deleted WebUI session must not resurface via the state.db projection.

    Regression for #5498 (second path): even when non-WebUI sessions are shown,
    _load_cli_sessions_uncached() projects source='webui' state.db rows into the
    sidebar. Without honoring the deleted-WebUI tombstone, a deleted session
    reappears as an 'Agent' ghost. The projection must skip a tombstoned
    source='webui' row that has no live sidecar.
    """
    import api.models as _m

    conn = _ensure_state_db()
    sid = 'webui_deleted_ghost_projection_001'
    cleanup_test_sessions.append(sid)
    try:
        _insert_gateway_session(conn, session_id=sid, source='webui', title='Deleted WebUI Ghost')
        _m._record_webui_deleted_session_tombstone(sid)
        # Tombstone should win: ensure no live sidecar exists for this sid.
        assert not (_m.SESSION_DIR / f'{sid}.json').exists()

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200
        ids = {s['session_id'] for s in data.get('sessions', [])}
        assert sid not in ids, (
            "deleted (tombstoned) WebUI session must not resurface in the sidebar projection"
        )
    finally:
        try:
            _m._clear_webui_deleted_session_tombstone(sid)
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_imported_cron_sessions_hidden_from_sidebar_by_default(cleanup_test_sessions):
    """Cron sessions already imported into the WebUI store should stay hidden from the sidebar."""
    from api.models import Session

    sid = 'cron_imported_20260427'
    cleanup_test_sessions.append(sid)
    s = Session(
        session_id=sid,
        title='Hourly Cron Import',
        messages=[{'role': 'user', 'content': 'run hourly job', 'timestamp': time.time()}],
        model='openai/gpt-5',
    )
    s.is_cli_session = True
    s.save(touch_updated_at=False)

    data, status = get('/api/sessions')
    assert status == 200
    assert sid not in {session.get('session_id') for session in data.get('sessions', [])}


def test_cron_sessions_hidden_from_sidebar_by_default():
    """Cron-run sessions are background/internal and should not appear in the default sidebar list."""
    conn = _ensure_state_db()
    try:
        _insert_gateway_session(conn, session_id='cron_job123_20260427', source='cron', title='Nightly Cleanup')
        _insert_gateway_session(conn, session_id='gw_noncron_001', source='telegram', title='Visible Chat')

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        ids = {s['session_id'] for s in sessions}
        assert 'gw_noncron_001' in ids, "Non-cron agent session should still appear"
        cron_row = next((s for s in sessions if s['session_id'] == 'cron_job123_20260427'), None)
        assert cron_row is None or cron_row.get('default_hidden') is True, (
            "Cron session should either be omitted from /api/sessions or marked "
            "default_hidden so the default sidebar filter keeps it hidden"
        )
    finally:
        try:
            _remove_test_sessions(conn, 'cron_job123_20260427', 'gw_noncron_001')
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_importable_agent_rows_can_opt_into_cron_source():
    """Diagnostic callers can opt out of the default cron exclusion explicitly."""
    conn = _ensure_state_db()
    try:
        _insert_agent_session_row(conn, session_id='cron_diag_20260427', source='cron', title='Cron Diagnostic')

        from api.agent_sessions import read_importable_agent_session_rows

        default_rows = read_importable_agent_session_rows(_get_state_db_path(), limit=None)
        assert 'cron_diag_20260427' not in {row.get('id') for row in default_rows}

        diagnostic_rows = read_importable_agent_session_rows(
            _get_state_db_path(),
            limit=None,
            exclude_sources=None,
        )
        assert 'cron_diag_20260427' in {row.get('id') for row in diagnostic_rows}
    finally:
        try:
            _remove_test_sessions(conn, 'cron_diag_20260427')
            conn.close()
        except Exception:
            pass


def test_gateway_session_has_message_count():
    """Gateway sessions report correct message_count from state.db."""
    conn = _ensure_state_db()
    try:
        _insert_gateway_session(conn, session_id='gw_msg_001', source='discord', title='Msg Count Test', message_count=5)

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        gw = next((s for s in sessions if s['session_id'] == 'gw_msg_001'), None)
        assert gw is not None
        assert gw.get('message_count') == 5, f"Expected message_count=5, got {gw.get('message_count')}"
    finally:
        try:
            _remove_test_sessions(conn, 'gw_msg_001')
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_gateway_sessions_multiple_sources():
    """Sessions from multiple gateway sources (telegram, discord, slack) all appear."""
    conn = _ensure_state_db()
    try:
        _insert_gateway_session(conn, session_id='gw_multi_tg', source='telegram', title='TG Chat')
        _insert_gateway_session(conn, session_id='gw_multi_dc', source='discord', title='DC Chat')
        _insert_gateway_session(conn, session_id='gw_multi_sl', source='slack', title='SL Chat')

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        gw_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')}
        assert len(gw_ids) == 3, f"Expected 3 gateway sessions, got {len(gw_ids)}: {gw_ids}"
    finally:
        try:
            _remove_test_sessions(conn, 'gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_gateway_session_messages_readable():
    """Gateway session messages can be loaded via /api/session."""
    conn = _ensure_state_db()
    try:
        _insert_gateway_session(conn, session_id='gw_read_001', source='telegram', title='Readable')

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get(f'/api/session?session_id=gw_read_001')
        assert status == 200
        msgs = data.get('session', {}).get('messages', [])
        assert len(msgs) >= 2, f"Expected at least 2 messages, got {len(msgs)}"
        assert msgs[0].get('role') == 'user'
        assert msgs[0].get('content') == 'Hello from Telegram'
    finally:
        try:
            _remove_test_sessions(conn, 'gw_read_001')
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


def test_session_prefers_state_db_messages_over_stale_local_snapshot(cleanup_test_sessions):
    """Stale local JSON for messaging sessions should not mask newer state.db messages."""
    from api.models import Session

    conn = _ensure_state_db()
    sid = 'gw_masking_regression_001'
    cleanup_test_sessions.append(sid)
    base_ts = time.time() - 120
    stale_messages = [
        ("user", "Old local user", base_ts + 1),
        ("assistant", "Old local assistant", base_ts + 2),
    ]
    fresh_messages = [
        ("user", "Fresh user 1", base_ts + 10),
        ("assistant", "Fresh assistant 1", base_ts + 11),
        ("user", "Fresh user 2", base_ts + 12),
        ("assistant", "Fresh assistant 2", base_ts + 13),
    ]
    expected_tail = fresh_messages[-1][1]
    expected_total = len(stale_messages) + len(fresh_messages)
    try:
        _insert_gateway_session(
            conn,
            session_id=sid,
            source='telegram',
            title='Regression Telegram Chat',
            message_count=expected_total,
            started_at=base_ts + 1,
        )
        # Replace the two auto-inserted starter messages with a controlled sequence
        # so we can assert ordering across local+state updates.
        conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
        for role, content, ts in stale_messages + fresh_messages:
            _insert_message(conn, sid, role, content, ts)
        conn.execute(
            "UPDATE sessions SET message_count = ? WHERE id = ?",
            (expected_total, sid),
        )
        conn.commit()

        s = Session(
            session_id=sid,
            title='Legacy Local Telegram Snapshot',
            workspace=str(pathlib.Path.home() / '.hermes'),
            model='openai/gpt-5',
            messages=[{"role": r, "content": c, "timestamp": t} for r, c, t in stale_messages],
        )
        s.is_cli_session = True
        s.session_source = 'messaging'
        s.source_tag = 'telegram'
        s.raw_source = 'telegram'
        s.source_label = 'Telegram'
        s.save(touch_updated_at=False)

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get(f'/api/session?session_id={sid}')
        assert status == 200, data
        session = data.get('session', {})
        msgs = session.get('messages', [])
        assert len(msgs) == expected_total, f"Expected {expected_total} messages, got {len(msgs)}"
        assert msgs[-1].get('content') == expected_tail
        assert session.get('message_count') == expected_total
    finally:
        try:
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass
        try:
            post('/api/settings', {'show_cli_sessions': False})
        except Exception:
            pass


def test_messaging_session_message_count_matches_deduped_display_messages(cleanup_test_sessions):
    """Thread sessions must not advertise raw DB rows that display merge dedupes away."""
    from api.models import Session

    conn = _ensure_state_db()
    sid = 'gw_display_count_regression_001'
    cleanup_test_sessions.append(sid)
    base_ts = time.time() - 60
    rows = [
        ("user", "Thread question", base_ts + 1),
        ("assistant", "", base_ts + 2),
        ("assistant", "", base_ts + 2),
        ("tool", '{"ok": true}', base_ts + 3),
        ("assistant", "Thread answer", base_ts + 4),
    ]
    raw_db_count = len(rows)
    try:
        _insert_gateway_session(
            conn,
            session_id=sid,
            source='discord',
            title='Discord Thread Count Regression',
            message_count=raw_db_count,
            started_at=base_ts,
        )
        conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
        for role, content, ts in rows:
            _insert_message(conn, sid, role, content, ts)
        conn.execute(
            "UPDATE sessions SET message_count = ? WHERE id = ?",
            (raw_db_count, sid),
        )
        conn.commit()

        # A stale WebUI sidecar can exist for the same messaging thread. The API
        # display merge dedupes repeated blank assistant separators, so the
        # advertised count must match the returned display coordinate space, not
        # the raw state.db row count.
        s = Session(
            session_id=sid,
            title='Legacy Discord Snapshot',
            workspace='/tmp/hermes-webui-test',
            model='openai/gpt-5',
            messages=[{"role": "user", "content": "Thread question", "timestamp": base_ts + 1}],
            session_source='messaging',
            raw_source='discord',
            source_tag='discord',
            source_label='Discord',
        )
        s.save(touch_updated_at=False)

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get(f'/api/session?session_id={sid}&messages=1&resolve_model=0&msg_limit=100')
        assert status == 200, data
        session = data.get('session', {})
        msgs = session.get('messages', [])
        assert msgs[-1].get('content') == 'Thread answer'
        assert len(msgs) < raw_db_count, "fixture must exercise display dedupe"
        assert session.get('message_count') == len(msgs)
    finally:
        try:
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass
        try:
            post('/api/settings', {'show_cli_sessions': False})
        except Exception:
            pass


def test_sessions_prefers_state_db_metadata_for_messaging_overlap(cleanup_test_sessions):
    """Sidebar metadata for messaging sessions should come from state.db, not local JSON snapshots."""
    conn = _ensure_state_db()
    sid = 'gw_sidebar_metadata_regression_001'
    cleanup_test_sessions.append(sid)
    now = time.time()
    rows = [
        ("user", "Hello", now - 30),
        ("assistant", "Welcome", now - 29),
        ("user", "Need details", now - 5),
    ]
    try:
        _insert_gateway_session(conn, session_id=sid, source='weixin', title='Live metadata chat', message_count=len(rows), started_at=now - 30)
        conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
        for role, content, ts in rows:
            _insert_message(conn, sid, role, content, ts)
        conn.commit()

        stale = [
            {"role": "user", "content": "stale one", "timestamp": now - 100},
            {"role": "assistant", "content": "stale two", "timestamp": now - 99},
        ]
        from api.models import Session
        local = Session(
            session_id=sid,
            title='Stale Sidebar',
            messages=stale,
            model='openai/gpt-4',
        )
        local.is_cli_session = True
        local.session_source = 'messaging'
        local.source_tag = 'weixin'
        local.raw_source = 'weixin'
        local.source_label = 'Weixin'
        local.save(touch_updated_at=False)

        post('/api/settings', {'show_cli_sessions': True})
        data, status = get('/api/sessions')
        assert status == 200, data
        session = next((item for item in data.get('sessions', []) if item.get('session_id') == sid), None)
        assert session is not None, f"{sid} missing from /api/sessions (test-isolation)"
        assert session.get('message_count') == len(rows)
        expected_updated = max(ts for _, _, ts in rows)
        assert abs(float(session.get('updated_at') or 0) - expected_updated) < 1.0
    finally:
        try:
            post('/api/settings', {'show_cli_sessions': False})
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass


def test_archiving_messaging_session_keeps_state_db_history(cleanup_test_sessions):
    """Archiving a messaging session should persist metadata without importing full transcript."""
    from api.models import Session

    conn = _ensure_state_db()
    sid = 'gw_archive_metadata_only_001'
    cleanup_test_sessions.append(sid)
    try:
        _insert_gateway_session(
            conn,
            session_id=sid,
            source='discord',
            title='Archive Safe',
            message_count=2,
            started_at=time.time() - 20,
        )
        # Do not create a local session first; archive should create minimal metadata only.
        data, status = post('/api/session/archive', {'session_id': sid, 'archived': True})
        assert status == 200, data
        archived = data.get('session', {})
        assert archived.get('archived') is True
        remaining = conn.execute(
            "SELECT COUNT(*) FROM messages WHERE session_id = ?",
            (sid,),
        ).fetchone()[0]
        assert remaining >= 2

        local = Session.load(sid)
        assert local is not None
        assert local.messages == [], "Archive should not import historical messages into local JSON"
        assert local.archived is True

        session_data, session_status = get(f'/api/session?session_id={sid}')
        assert session_status == 200, session_data
        assert session_data.get('session', {}).get('archived') is True
        assert session_data.get('session', {}).get('message_count') == 2
    finally:
        try:
            _remove_test_sessions(conn, sid)
            conn.close()
        except Exception:
            pass


def test_importing_older_gateway_session_preserves_original_timestamps_and_order():
    """Importing an older gateway session should not bump it above newer WebUI sessions."""
    conn = _ensure_state_db()
    older_started_at = time.time() - 1800
    imported_sid = 'gw_import_old_001'
    newer_webui_sid = None
    try:
        newer_webui, status = post('/api/session/new', {'model': 'openai/gpt-5'})
        assert status == 200, newer_webui
        newer_webui_sid = newer_webui['session']['session_id']

        rename, rename_status = post(
            '/api/session/rename',
            {'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
        )
        assert rename_status == 200, rename
        from api.models import Session
        from tests.conftest import TEST_WORKSPACE
        newer_webui_session = Session(
            session_id=newer_webui_sid,
            title='Newer WebUI Session',
            workspace=str(TEST_WORKSPACE),
            model='openai/gpt-5',
            created_at=newer_webui['session']['created_at'],
            updated_at=time.time(),
            profile='default',
            messages=[{
                'role': 'user',
                'content': 'newer visible row',
                'timestamp': time.time(),
            }],
            tool_calls=[],
        )
        newer_webui_session.save(touch_updated_at=False)
        rename_refresh, rename_refresh_status = post(
            '/api/session/rename',
            {'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
        )
        assert rename_refresh_status == 200, rename_refresh

        _insert_gateway_session(
            conn,
            session_id=imported_sid,
            source='discord',
            title='Older imported gateway session',
            started_at=older_started_at,
        )
        post('/api/settings', {'show_cli_sessions': True})

        imported, imported_status = post('/api/session/import_cli', {'session_id': imported_sid})
        assert imported_status == 200, imported
        imported_session = imported['session']
        assert abs(imported_session['created_at'] - older_started_at) < 2, imported_session
        assert abs(imported_session['updated_at'] - older_started_at) < 5, imported_session

        sessions_payload, sessions_status = get('/api/sessions')
        assert sessions_status == 200, sessions_payload
        ordered_ids = [item['session_id'] for item in sessions_payload.get('sessions', [])]
        assert newer_webui_sid in ordered_ids, ordered_ids
        assert imported_sid in ordered_ids, ordered_ids
        assert ordered_ids.index(newer_webui_sid) < ordered_ids.index(imported_sid), ordered_ids
    finally:
        try:
            _remove_test_sessions(conn, imported_sid)
            conn.close()
        except Exception:
            pass
        if imported_sid:
            try:
                post('/api/session/delete', {'session_id': imported_sid})
            except Exception:
                pass
        if newer_webui_sid:
            try:
                post('/api/session/delete', {'session_id': newer_webui_sid})
            except Exception:
                pass
        post('/api/settings', {'show_cli_sessions': False})



def test_gateway_sse_stream_endpoint_exists():
    """GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
    # The SSE endpoint requires show_cli_sessions to be enabled
    post('/api/settings', {'show_cli_sessions': True})
    try:
        req = urllib.request.Request(BASE + '/api/sessions/gateway/stream')
        with urllib.request.urlopen(req, timeout=5) as r:
            assert r.status in (200, 204), f"Expected 200/204, got {r.status}"
            # SSE should have content-type text/event-stream
            ctype = r.headers.get('Content-Type', '')
            assert 'text/event-stream' in ctype, f"Expected text/event-stream, got {ctype}"
    except Exception as e:
        # Timeout is acceptable — means the connection is held open (SSE behavior)
        if 'timed out' in str(e).lower() or 'timeout' in str(e).lower():
            pass  # Good: SSE keeps the connection open
        else:
            raise
    finally:
        post('/api/settings', {'show_cli_sessions': False})


def test_gateway_sse_stream_probe_reports_status():
    """Probe mode returns JSON watcher status instead of holding open an SSE stream."""
    post('/api/settings', {'show_cli_sessions': True})
    try:
        req = urllib.request.Request(BASE + '/api/sessions/gateway/stream?probe=1')
        with urllib.request.urlopen(req, timeout=5) as r:
            assert r.status == 200, f"Expected 200, got {r.status}"
            ctype = r.headers.get('Content-Type', '')
            assert 'application/json' in ctype, f"Expected application/json, got {ctype}"
            data = json.loads(r.read().decode('utf-8'))
            assert data['enabled'] is True
            assert 'watcher_running' in data
            assert data['fallback_poll_ms'] == 30000
            # Cross-client scope markers: the probe is scoped to the optional
            # gateway stream and must not imply session SSE is unavailable.
            assert data['scope'] == 'gateway_sessions'
            assert data['session_stream_available'] is True
            assert data['session_stream_path'] == '/api/session/stream'
    finally:
        post('/api/settings', {'show_cli_sessions': False})


def test_gateway_sse_stream_probe_disabled_keeps_session_stream_markers():
    """Disabled probe (404) still signals that /api/session/stream is usable.

    Regression for hermes-webui/hermes-android#58: a client probing the gateway
    stream while 'agent sessions' are off must be able to tell that persistent
    per-session streaming remains available instead of classifying all SSE as
    unsupported.
    """
    post('/api/settings', {'show_cli_sessions': False})
    req = urllib.request.Request(BASE + '/api/sessions/gateway/stream?probe=1')
    try:
        urllib.request.urlopen(req, timeout=5)
        raise AssertionError('Expected 404 when agent sessions are disabled')
    except urllib.error.HTTPError as e:
        assert e.code == 404, f"Expected 404, got {e.code}"
        data = json.loads(e.read().decode('utf-8'))
        assert data['ok'] is False
        assert data['enabled'] is False
        assert data['error'] == 'agent sessions not enabled'
        # The negative gateway result is scoped: session streaming stays usable.
        assert data['scope'] == 'gateway_sessions'
        assert data['session_stream_available'] is True
        assert data['session_stream_path'] == '/api/session/stream'


def test_gateway_webui_sessions_not_duplicated():
    """If a session_id exists both in WebUI store and state.db, it's not duplicated."""
    # Create a WebUI session with a known ID
    body = {}
    d, _ = post('/api/session/new', body)
    webui_sid = d['session']['session_id']

    try:
        # Insert the same session_id into state.db as a gateway session
        conn = _ensure_state_db()
        _insert_gateway_session(conn, session_id=webui_sid, source='telegram', title='Dup Test')
        conn.close()

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        matching = [s for s in sessions if s['session_id'] == webui_sid]
        assert len(matching) == 1, f"Expected 1 entry for {webui_sid}, got {len(matching)}"
    finally:
        try:
            conn2 = sqlite3.connect(str(_get_state_db_path()))
            _remove_test_sessions(conn2, webui_sid)
            conn2.close()
        except Exception:
            pass
        post('/api/session/delete', {'session_id': webui_sid})
        post('/api/settings', {'show_cli_sessions': False})


def test_gateway_sessions_no_state_db():
    """When state.db doesn't exist, /api/sessions works fine (no gateway sessions)."""
    _cleanup_state_db()

    post('/api/settings', {'show_cli_sessions': True})
    try:
        data, status = get('/api/sessions')
        assert status == 200
        # Should succeed with just webui sessions (or empty)
        assert 'sessions' in data
    finally:
        post('/api/settings', {'show_cli_sessions': False})


def test_cli_sessions_still_work():
    """CLI sessions (source='cli') still appear alongside gateway sessions."""
    conn = _ensure_state_db()
    try:
        _insert_gateway_session(conn, session_id='cli_legacy_001', source='cli', title='CLI Legacy')
        _insert_gateway_session(conn, session_id='gw_new_001', source='telegram', title='GW New')

        post('/api/settings', {'show_cli_sessions': True})

        data, status = get('/api/sessions')
        assert status == 200
        sessions = data.get('sessions', [])
        agent_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('cli_legacy_001', 'gw_new_001')}
        assert len(agent_ids) == 2, f"Expected 2 agent sessions (cli + gateway), got {len(agent_ids)}"
    finally:
        try:
            _remove_test_sessions(conn, 'cli_legacy_001', 'gw_new_001')
            conn.close()
        except Exception:
            pass
        post('/api/settings', {'show_cli_sessions': False})


# ── Unit tests for _gateway_sse_probe_payload ────────────────────────────────
# These replace the deleted repo-root test_gateway_sse_probe_unit.py and account
# for the watcher_alive check (thread existence + is_alive()).

import sys
import threading
sys.path.insert(0, str(REPO_ROOT))
from api.routes import _gateway_sse_probe_payload


def test_probe_payload_when_disabled():
    """Probe returns 404 when show_cli_sessions is False."""
    body, status = _gateway_sse_probe_payload({'show_cli_sessions': False}, watcher=None)
    assert status == 404
    assert body['ok'] is False
    assert body['enabled'] is False
    assert body['watcher_running'] is False
    assert body['error'] == 'agent sessions not enabled'
    assert body['fallback_poll_ms'] == 30000
    # Scope markers stay present on the negative result so clients do not
    # misread "gateway SSE off" as "session SSE unavailable".
    assert body['scope'] == 'gateway_sessions'
    assert body['session_stream_available'] is True
    assert body['session_stream_path'] == '/api/session/stream'


def test_probe_payload_when_watcher_missing():
    """Probe returns 503 when enabled but no watcher instance."""
    body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=None)
    assert status == 503
    assert body['ok'] is False
    assert body['enabled'] is True
    assert body['watcher_running'] is False
    assert body['error'] == 'watcher not started'
    assert body['fallback_poll_ms'] == 30000
    assert body['scope'] == 'gateway_sessions'
    assert body['session_stream_available'] is True


def test_probe_payload_when_watcher_instance_no_thread():
    """Probe returns 503 when watcher exists but _thread attribute is missing/None."""
    class _FakeWatcher:
        _thread = None
    body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=_FakeWatcher())
    assert status == 503
    assert body['watcher_running'] is False


def test_probe_payload_when_watcher_thread_alive():
    """Probe returns 200 when enabled and watcher thread is alive."""
    class _FakeWatcher:
        pass
    w = _FakeWatcher()
    t = threading.Thread(target=lambda: None)
    t.daemon = True
    t.start()
    w._thread = t
    # Thread may finish fast — loop-start a live daemon thread for reliability
    import time as _time
    done = threading.Event()
    live = threading.Thread(target=done.wait, daemon=True)
    live.start()
    w._thread = live
    try:
        body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
        assert status == 200
        assert body['ok'] is True
        assert body['watcher_running'] is True
        assert body['fallback_poll_ms'] == 30000
        assert body['scope'] == 'gateway_sessions'
        assert body['session_stream_available'] is True
    finally:
        done.set()
        live.join(timeout=1)


def test_probe_payload_when_watcher_thread_dead():
    """Probe returns 503 when watcher instance exists but thread has exited."""
    class _FakeWatcher:
        pass
    w = _FakeWatcher()
    t = threading.Thread(target=lambda: None)
    t.start()
    t.join()  # wait for it to finish
    w._thread = t
    body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
    assert status == 503
    assert body['watcher_running'] is False
    assert body['ok'] is False


def test_gateway_watcher_is_alive_public_method():
    """GatewayWatcher.is_alive() is the public API the probe uses. Cover all
    three states: before start(), while running, after stop()."""
    from api.gateway_watcher import GatewayWatcher
    w = GatewayWatcher()
    # Before start(): no thread
    assert w.is_alive() is False, "is_alive() must be False before start()"
    # After start(): thread running
    w.start()
    try:
        assert w.is_alive() is True, "is_alive() must be True while running"
    finally:
        w.stop()
    # After stop(): thread cleared
    assert w.is_alive() is False, "is_alive() must be False after stop()"


def test_probe_payload_prefers_public_is_alive():
    """Regression guard: _gateway_sse_probe_payload must call watcher.is_alive()
    rather than poking at _thread directly when the public method exists."""
    calls = []

    class _WatcherWithPublicApi:
        def is_alive(self):
            calls.append('is_alive')
            return True
        # _thread is deliberately absent — must not be accessed.

    body, status = _gateway_sse_probe_payload(
        {'show_cli_sessions': True},
        watcher=_WatcherWithPublicApi(),
    )
    assert status == 200
    assert body['watcher_running'] is True
    assert calls == ['is_alive'], (
        "probe must prefer the public is_alive() method over poking _thread"
    )
