"""ACP-sourced state.db sessions must be visible in the sidebar.

The gateway's ACP adapter (Agent Client Protocol — Zed, external device
bridges such as a Rabbit R1) persists sessions to state.db with
``source='acp'``. Before this fix, 'acp' normalised to session_source
'other', which fell through BOTH sidebar buckets:

- ``sidebar_source=webui`` skips the state.db projection entirely, and an
  ACP session has no WebUI sidecar, so it never appeared there.
- ``sidebar_source=cli`` keeps only CLI-classified rows
  (``_is_cli_session_for_settings``), which dropped 'other' rows.

ACP is a local interactive agent client like the CLI/TUI, so it is now
classified into the CLI family.
"""

import sqlite3
import time
import unittest.mock

import api.models as models
from api.agent_sessions import (
    is_cli_session_row,
    is_cli_session_row_visible,
    normalize_agent_session_source,
)


def test_acp_normalizes_to_cli_family():
    meta = normalize_agent_session_source('acp')
    assert meta['session_source'] == 'cli'
    assert meta['raw_source'] == 'acp'
    assert meta['source_label'] == 'ACP'


def test_acp_row_is_cli_classified():
    row = {'id': 'e91ffb31-654c-4b62-9ca1-6ecc16423899', 'source': 'acp'}
    assert is_cli_session_row({**row, **normalize_agent_session_source('acp')}) is True
    # Raw rows (no prior normalization) must classify the same way — several
    # callers pass sidecar/state rows that only carry source metadata.
    assert is_cli_session_row(row) is True


def test_acp_row_with_messages_stays_visible_even_when_ended():
    """Ended/untitled ACP rows are user-driven conversations, never framework noise."""
    row = {
        'id': 'acp-ended',
        'source': 'acp',
        'title': 'Acp Session',  # default-shaped title
        'message_count': 4,
        'actual_message_count': 4,
        'actual_user_message_count': 1,  # below CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
        'ended_at': 1751000000.0,
        'end_reason': 'client_disconnect',
    }
    assert is_cli_session_row_visible({**row, **normalize_agent_session_source('acp')}) is True


def test_empty_acp_row_stays_hidden():
    """Zero-message ACP rows (connect/reconnect stubs) must not clutter the sidebar."""
    row = {
        'id': 'acp-empty',
        'source': 'acp',
        'title': None,
        'message_count': 0,
        'actual_message_count': 0,
        'actual_user_message_count': 0,
    }
    assert is_cli_session_row_visible({**row, **normalize_agent_session_source('acp')}) is False


def test_acp_row_without_user_turns_stays_hidden():
    """An ACP row holding only assistant/tool/system messages is not user-driven.

    A replayed or aborted turn can leave an ended ACP connection with a
    positive message_count but zero user turns — it must not surface in the
    sidebar (Greptile review on #5939).
    """
    row = {
        'id': 'acp-no-user-turns',
        'source': 'acp',
        'title': 'Replayed segment',
        'message_count': 3,
        'actual_message_count': 3,
        'actual_user_message_count': 0,
        'ended_at': 1751000000.0,
        'end_reason': 'client_disconnect',
    }
    assert is_cli_session_row_visible({**row, **normalize_agent_session_source('acp')}) is False


# --- state.db projection (mirrors tests/test_issue3586_cli_session_source_label.py) ---

def _make_state_db(path, sessions):
    conn = sqlite3.connect(str(path))
    conn.executescript(
        """
        CREATE TABLE sessions (
            id TEXT PRIMARY KEY,
            source TEXT,
            session_source TEXT,
            title TEXT,
            model TEXT,
            started_at REAL NOT NULL,
            message_count INTEGER DEFAULT 0,
            parent_session_id TEXT,
            ended_at REAL,
            end_reason TEXT
        );
        CREATE INDEX idx_sessions_started ON sessions(started_at);
        CREATE TABLE messages (
            id TEXT PRIMARY KEY,
            session_id TEXT,
            role TEXT,
            content TEXT,
            timestamp REAL
        );
        CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
        """
    )
    base = time.time() - len(sessions)
    for i, sess in enumerate(sessions):
        sid = sess['id']
        started = base + i
        conn.execute(
            """
            INSERT INTO sessions
            (id, source, session_source, title, model, started_at, message_count,
             parent_session_id, ended_at, end_reason)
            VALUES (?, ?, NULL, ?, 'deepseek/deepseek-chat', ?, 2, NULL, NULL, NULL)
            """,
            (sid, sess['source'], sess.get('title', sid), started),
        )
        conn.execute(
            "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, 'user', 'hello', ?)",
            (f"msg_{sid}_0", sid, started),
        )
        conn.execute(
            "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, 'user', 'follow-up', ?)",
            (f"msg_{sid}_1", sid, started + 0.1),
        )
        conn.execute(
            "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, 'assistant', 'hi', ?)",
            (f"msg_{sid}_2", sid, started + 0.2),
        )
    conn.commit()
    conn.close()


def _call_uncached(tmp_path, sessions):
    db = tmp_path / 'state.db'
    _make_state_db(db, sessions)
    with (
        unittest.mock.patch('api.models.get_claude_code_sessions', return_value=[]),
        unittest.mock.patch('api.models.ensure_cron_project', return_value='cron-project-id'),
    ):
        return models._load_cli_sessions_uncached(tmp_path, db, None)


def test_acp_session_appears_in_projection_as_cli(tmp_path):
    """An ACP state.db row must surface in the sidebar projection, CLI-classified."""
    sessions = [
        {
            'id': 'e91ffb31-654c-4b62-9ca1-6ecc16423899',
            'source': 'acp',
            'title': 'Raumanalyse mit Schimmelfleck-Beurteilung',
        },
    ]
    results = _call_uncached(tmp_path, sessions)
    row = next(
        (s for s in results if s['session_id'] == 'e91ffb31-654c-4b62-9ca1-6ecc16423899'),
        None,
    )
    assert row is not None, "acp session should appear in the sidebar projection"
    assert row['is_cli_session'] is True
    assert row['session_source'] == 'cli'
    assert row['source_label'] == 'ACP'


def test_newer_kanban_rows_do_not_evict_cli_sessions_from_projection(tmp_path):
    """Kanban has a bounded window separate from interactive conversations."""
    kanban_count = models.KANBAN_PROJECT_CHIP_LIMIT + 5
    sessions = [
        {
            'id': 'older-cli-session',
            'source': 'cli',
            'title': 'CLI conversation',
        },
        *[
            {
                'id': f'newer-kanban-{i:03d}',
                'source': 'kanban',
                'title': f'Kanban worker {i:03d}',
            }
            for i in range(kanban_count)
        ],
    ]

    results = _call_uncached(tmp_path, sessions)
    result_ids = {row['session_id'] for row in results}
    kanban_rows = [row for row in results if row.get('source_tag') == 'kanban']

    assert 'older-cli-session' in result_ids
    assert len(kanban_rows) == models.KANBAN_PROJECT_CHIP_LIMIT
    assert {row['session_id'] for row in kanban_rows} == {
        f'newer-kanban-{i:03d}'
        for i in range(kanban_count - models.KANBAN_PROJECT_CHIP_LIMIT, kanban_count)
    }

    from api.routes import _dedupe_cli_sidebar_sessions_for_api

    hidden = _dedupe_cli_sidebar_sessions_for_api(
        results,
        set(),
        show_kanban_sessions=False,
    )
    visible = _dedupe_cli_sidebar_sessions_for_api(
        results,
        set(),
        show_kanban_sessions=True,
    )
    assert not any(row.get('source_tag') == 'kanban' for row in hidden)
    assert sum(row.get('source_tag') == 'kanban' for row in visible) == models.KANBAN_PROJECT_CHIP_LIMIT
