"""
Tests for Story 4.07 — ConversationReplayEngine.get_recent_summary()

BB1: 5 Kinan conversations in DB → get_recent_summary(n=3) returns 3 most recent
BB2: 0 conversations → returns ""
BB3: n=1 → returns only most recent

WB1: Postgres query uses LIMIT n (not Python slice)
WB2: Call duration calculated from started_at + ended_at
WB3: action_items formatted inline, not as raw JSON

ALL database calls are fully mocked — zero real I/O.
No SQLite — RULE 7 compliance enforced.
"""
import asyncio
import inspect
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch, call

import pytest

import sys
sys.path.insert(0, "/mnt/e/genesis-system")

from core.memory.conversation_replay import (
    ConversationReplayEngine,
    _format_duration,
    _format_action_items,
    _format_row,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _dt(year: int, month: int, day: int, hour: int = 10) -> datetime:
    """Convenience: create a timezone-aware datetime."""
    return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)


def _make_row(
    started_at: datetime,
    ended_at=None,
    summary: str = "Test summary",
    action_items=None,
) -> dict:
    """Build a minimal royal_conversations row dict."""
    return {
        "started_at": started_at,
        "ended_at": ended_at,
        "summary": summary,
        "action_items": action_items,
    }


def _make_pool(rows: list) -> MagicMock:
    """
    Build a mock psycopg2 connection pool that returns `rows` from fetchall().

    Pool protocol:
        pool.getconn() → conn
        conn.cursor() → ctx manager → cursor
        cursor.execute(sql, params) — captured for WB1
        cursor.description → [(col_name, ...), ...]
        cursor.fetchall() → list of tuples matching description columns
        pool.putconn(conn)
    """
    columns = ["started_at", "ended_at", "summary", "action_items"]
    description = [(col, None, None, None, None, None, None) for col in columns]

    # Convert list-of-dicts to list-of-tuples (matching column order)
    tuples = [
        (r["started_at"], r["ended_at"], r["summary"], r["action_items"])
        for r in rows
    ]

    cursor = MagicMock()
    cursor.__enter__ = MagicMock(return_value=cursor)
    cursor.__exit__ = MagicMock(return_value=False)
    cursor.description = description
    cursor.fetchall = MagicMock(return_value=tuples)
    cursor.execute = MagicMock()  # captured for WB1

    conn = MagicMock()
    conn.cursor = MagicMock(return_value=cursor)

    pool = MagicMock()
    pool.getconn = MagicMock(return_value=conn)
    pool.putconn = MagicMock()

    # Expose cursor for assertions in tests
    pool._cursor = cursor
    pool._conn = conn

    return pool


def run(coro):
    """Run a coroutine synchronously."""
    return asyncio.get_event_loop().run_until_complete(coro)


# ---------------------------------------------------------------------------
# Package guards
# ---------------------------------------------------------------------------

class TestPackageGuards:
    def test_no_sqlite_import(self):
        """sqlite3 must NEVER appear in conversation_replay.py (Rule 7)."""
        import core.memory.conversation_replay as mod
        source = inspect.getsource(mod)
        assert "import sqlite3" not in source, (
            "sqlite3 is BANNED (Rule 7) — found in conversation_replay"
        )

    def test_module_importable(self):
        """Module must import without errors."""
        import core.memory.conversation_replay  # noqa: F401

    def test_engine_importable(self):
        """ConversationReplayEngine must be importable from the module."""
        from core.memory.conversation_replay import ConversationReplayEngine  # noqa
        assert ConversationReplayEngine is not None

    def test_get_recent_summary_is_coroutine(self):
        """get_recent_summary must be declared async."""
        assert inspect.iscoroutinefunction(
            ConversationReplayEngine.get_recent_summary
        ), "get_recent_summary must be an async def"


# ---------------------------------------------------------------------------
# BB1: 5 Kinan conversations → get_recent_summary(n=3) returns 3 most recent
# ---------------------------------------------------------------------------

class TestBB1FiveConversationsReturnThree:
    def test_returns_string(self):
        """get_recent_summary must return a str."""
        rows = [_make_row(_dt(2026, 2, 24 - i)) for i in range(5)]
        pool = _make_pool(rows[:3])  # DB already limits to 3 via LIMIT
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=3))
        assert isinstance(result, str), f"Expected str, got {type(result).__name__}"

    def test_result_starts_with_recent_context_header(self):
        """Non-empty result must start with 'RECENT CONTEXT:'."""
        rows = [
            _make_row(_dt(2026, 2, 24), summary="Alpha"),
            _make_row(_dt(2026, 2, 23), summary="Beta"),
            _make_row(_dt(2026, 2, 22), summary="Gamma"),
        ]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=3))
        assert result.startswith("RECENT CONTEXT:"), (
            f"Expected 'RECENT CONTEXT:' header, got: {result[:60]!r}"
        )

    def test_result_has_exactly_three_lines_after_header(self):
        """With 3 rows returned by pool, result must have 3 conversation lines."""
        rows = [
            _make_row(_dt(2026, 2, 24), summary="Alpha"),
            _make_row(_dt(2026, 2, 23), summary="Beta"),
            _make_row(_dt(2026, 2, 22), summary="Gamma"),
        ]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=3))
        lines = result.strip().splitlines()
        # lines[0] = "RECENT CONTEXT:", lines[1..3] = conversation lines
        assert len(lines) == 4, (
            f"Expected header + 3 conversation lines (4 total), got {len(lines)}: {lines}"
        )

    def test_result_lines_contain_summaries(self):
        """Each conversation line must contain its summary text."""
        summaries = ["Alpha summary", "Beta summary", "Gamma summary"]
        rows = [
            _make_row(_dt(2026, 2, 24), summary=summaries[0]),
            _make_row(_dt(2026, 2, 23), summary=summaries[1]),
            _make_row(_dt(2026, 2, 22), summary=summaries[2]),
        ]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=3))
        for s in summaries:
            assert s in result, f"Summary '{s}' not found in result:\n{result}"

    def test_result_lines_contain_dates(self):
        """Each conversation line must contain its date in [YYYY-MM-DD] format."""
        rows = [
            _make_row(_dt(2026, 2, 24), summary="A"),
            _make_row(_dt(2026, 2, 23), summary="B"),
            _make_row(_dt(2026, 2, 22), summary="C"),
        ]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=3))
        assert "[2026-02-24]" in result, "Date 2026-02-24 not found"
        assert "[2026-02-23]" in result, "Date 2026-02-23 not found"
        assert "[2026-02-22]" in result, "Date 2026-02-22 not found"


# ---------------------------------------------------------------------------
# BB2: 0 conversations → returns ""
# ---------------------------------------------------------------------------

class TestBB2ZeroConversations:
    def test_empty_db_returns_empty_string(self):
        """When the DB returns 0 rows, get_recent_summary must return ''."""
        pool = _make_pool([])
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=3))
        assert result == "", f"Expected '', got: {result!r}"

    def test_returns_string_not_none(self):
        """Must return str, never None."""
        pool = _make_pool([])
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=3))
        assert result is not None, "get_recent_summary must never return None"
        assert isinstance(result, str)

    def test_no_pool_returns_empty_string(self):
        """When postgres_pool is None, must return '' without raising."""
        engine = ConversationReplayEngine(postgres_pool=None)
        result = run(engine.get_recent_summary(n=3))
        assert result == "", f"Expected '', got: {result!r}"

    def test_pool_exception_returns_empty_string(self):
        """When pool.getconn() raises, must return '' gracefully."""
        pool = MagicMock()
        pool.getconn = MagicMock(side_effect=Exception("PG connection refused"))
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=3))
        assert result == "", f"Expected '', got: {result!r}"


# ---------------------------------------------------------------------------
# BB3: n=1 → returns only most recent
# ---------------------------------------------------------------------------

class TestBB3NEquals1:
    def test_n1_returns_header_plus_one_line(self):
        """When n=1, result must have header + exactly 1 conversation line."""
        rows = [_make_row(_dt(2026, 2, 24), summary="Only one")]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=1))
        lines = result.strip().splitlines()
        assert len(lines) == 2, (
            f"Expected header + 1 line (2 total) for n=1, got {len(lines)}: {lines}"
        )

    def test_n1_result_contains_summary(self):
        """n=1 result must contain the single summary."""
        rows = [_make_row(_dt(2026, 2, 24), summary="Unique summary here")]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=1))
        assert "Unique summary here" in result, (
            f"Summary not found in n=1 result: {result!r}"
        )

    def test_n1_result_contains_correct_date(self):
        """n=1 result must contain the date of that one row."""
        rows = [_make_row(_dt(2026, 1, 15), summary="January call")]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=1))
        assert "[2026-01-15]" in result, (
            f"Date [2026-01-15] not found in: {result!r}"
        )


# ---------------------------------------------------------------------------
# WB1: Postgres query uses LIMIT n (not Python slice)
# ---------------------------------------------------------------------------

class TestWB1QueryUsesLimitN:
    def test_execute_called_with_limit_parameter(self):
        """
        cursor.execute must be called with a parameterised query and (n,) as args.
        The LIMIT must be in the SQL, not applied in Python.
        """
        rows = [_make_row(_dt(2026, 2, 24), summary="Test")]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        run(engine.get_recent_summary(n=5))

        cursor = pool._cursor
        cursor.execute.assert_called_once()
        call_args = cursor.execute.call_args

        sql = call_args[0][0]  # first positional arg
        params = call_args[0][1]  # second positional arg

        assert "LIMIT" in sql.upper(), f"SQL must contain LIMIT: {sql!r}"
        assert "%s" in sql, f"SQL must use parameterised %s: {sql!r}"
        assert params == (5,), f"LIMIT param must be (5,), got {params!r}"

    def test_limit_n_respected_for_different_values(self):
        """Query param must match the n passed to get_recent_summary."""
        for n_val in (1, 3, 10):
            rows = [_make_row(_dt(2026, 2, 24), summary="X")]
            pool = _make_pool(rows)
            engine = ConversationReplayEngine(postgres_pool=pool)
            run(engine.get_recent_summary(n=n_val))
            cursor = pool._cursor
            params = cursor.execute.call_args[0][1]
            assert params == (n_val,), (
                f"For n={n_val}, expected params=({n_val},), got {params!r}"
            )

    def test_sql_filters_on_kinan_participant(self):
        """SQL must filter participants->>'kinan' = 'true'."""
        rows = [_make_row(_dt(2026, 2, 24), summary="Test")]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        run(engine.get_recent_summary(n=3))
        cursor = pool._cursor
        sql = cursor.execute.call_args[0][0]
        assert "kinan" in sql.lower(), (
            f"SQL must filter on kinan participant, got: {sql!r}"
        )

    def test_sql_orders_by_started_at_desc(self):
        """SQL must ORDER BY started_at DESC."""
        rows = [_make_row(_dt(2026, 2, 24), summary="Test")]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        run(engine.get_recent_summary(n=3))
        cursor = pool._cursor
        sql = cursor.execute.call_args[0][0].upper()
        assert "ORDER BY" in sql and "DESC" in sql, (
            f"SQL must contain ORDER BY ... DESC, got: {sql!r}"
        )

    def test_getconn_and_putconn_called(self):
        """Pool getconn() and putconn() must both be called (connection returned)."""
        rows = [_make_row(_dt(2026, 2, 24), summary="Test")]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        run(engine.get_recent_summary(n=3))
        pool.getconn.assert_called_once()
        pool.putconn.assert_called_once_with(pool._conn)

    def test_putconn_called_even_on_exception(self):
        """putconn must be called even if fetchall raises (try/finally)."""
        cursor = MagicMock()
        cursor.__enter__ = MagicMock(return_value=cursor)
        cursor.__exit__ = MagicMock(return_value=False)
        cursor.description = [("started_at", None, None, None, None, None, None)]
        cursor.fetchall = MagicMock(side_effect=RuntimeError("DB blew up"))
        cursor.execute = MagicMock()

        conn = MagicMock()
        conn.cursor = MagicMock(return_value=cursor)

        pool = MagicMock()
        pool.getconn = MagicMock(return_value=conn)
        pool.putconn = MagicMock()

        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=3))

        # Must not raise, must return ""
        assert result == "", f"Expected '' on DB error, got: {result!r}"
        # putconn must be called (try/finally)
        pool.putconn.assert_called_once_with(conn)


# ---------------------------------------------------------------------------
# WB2: Call duration calculated from started_at + ended_at
# ---------------------------------------------------------------------------

class TestWB2DurationCalculation:
    def test_duration_18_minutes(self):
        """18-minute call → 'Call (18min)'."""
        started = _dt(2026, 2, 24, 10)
        ended = started + timedelta(minutes=18)
        assert _format_duration(started, ended) == "18min"

    def test_duration_6_minutes(self):
        """6-minute call → 'Call (6min)'."""
        started = _dt(2026, 2, 24, 10)
        ended = started + timedelta(minutes=6)
        assert _format_duration(started, ended) == "6min"

    def test_duration_0_minutes(self):
        """30-second call → '0min' (truncated, not rounded)."""
        started = _dt(2026, 2, 24, 10)
        ended = started + timedelta(seconds=30)
        assert _format_duration(started, ended) == "0min"

    def test_duration_none_ended_at_returns_ongoing(self):
        """ended_at=None → 'ongoing'."""
        started = _dt(2026, 2, 24, 10)
        assert _format_duration(started, None) == "ongoing"

    def test_duration_appears_in_formatted_row(self):
        """Duration must appear inside '(...)' in the formatted line."""
        started = _dt(2026, 2, 24, 10)
        ended = started + timedelta(minutes=45)
        row = _make_row(started, ended_at=ended, summary="Long call")
        line = _format_row(row)
        assert "(45min)" in line, f"Expected '(45min)' in: {line!r}"

    def test_ongoing_appears_when_no_ended_at(self):
        """'ongoing' must appear in line when ended_at is None."""
        row = _make_row(_dt(2026, 2, 24), ended_at=None, summary="Active call")
        line = _format_row(row)
        assert "(ongoing)" in line, f"Expected '(ongoing)' in: {line!r}"

    def test_duration_in_full_pipeline(self):
        """Duration must be correct when flowing through get_recent_summary."""
        started = _dt(2026, 2, 24, 10)
        ended = started + timedelta(minutes=22)
        rows = [_make_row(started, ended_at=ended, summary="Pipeline test")]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=1))
        assert "(22min)" in result, f"Expected '(22min)' in: {result!r}"


# ---------------------------------------------------------------------------
# WB3: action_items formatted inline, not as raw JSON
# ---------------------------------------------------------------------------

class TestWB3ActionItemsFormatted:
    def test_list_action_items_joined_with_semicolon(self):
        """action_items list → 'Action: item1; item2'."""
        result = _format_action_items(["Follow up", "Check number"])
        assert result == " Action: Follow up; Check number", (
            f"Got: {result!r}"
        )

    def test_single_item_list(self):
        """Single-item list → 'Action: item'."""
        result = _format_action_items(["Send invoice"])
        assert result == " Action: Send invoice", f"Got: {result!r}"

    def test_empty_list_returns_empty_string(self):
        """Empty list → '' (Action part omitted)."""
        result = _format_action_items([])
        assert result == "", f"Expected '', got: {result!r}"

    def test_none_returns_empty_string(self):
        """None → '' (Action part omitted)."""
        result = _format_action_items(None)
        assert result == "", f"Expected '', got: {result!r}"

    def test_json_string_list_parsed_and_formatted(self):
        """JSON string representing a list → parsed and formatted."""
        json_str = '["Book appointment", "Send confirmation"]'
        result = _format_action_items(json_str)
        assert result == " Action: Book appointment; Send confirmation", (
            f"Got: {result!r}"
        )

    def test_empty_json_array_string_returns_empty(self):
        """'[]' string → '' (no Action part)."""
        result = _format_action_items("[]")
        assert result == "", f"Expected '', got: {result!r}"

    def test_action_items_not_raw_json_in_output(self):
        """The output must not contain raw JSON brackets like '[' or ']'."""
        action = ["Follow up", "Check number"]
        result = _format_action_items(action)
        assert "[" not in result, f"Raw JSON '[' found in: {result!r}"
        assert "]" not in result, f"Raw JSON ']' found in: {result!r}"

    def test_action_items_in_full_pipeline(self):
        """action_items must be formatted inline in get_recent_summary output."""
        started = _dt(2026, 2, 24, 10)
        rows = [
            _make_row(
                started,
                summary="George Cairns check",
                action_items=["Follow up", "Book call"],
            )
        ]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=1))
        assert "Action: Follow up; Book call" in result, (
            f"Formatted action items not found in: {result!r}"
        )
        # Must NOT contain raw JSON
        assert '["Follow up"' not in result, f"Raw JSON found in: {result!r}"

    def test_no_action_in_line_when_empty_items(self):
        """When action_items is empty, 'Action:' must NOT appear in output."""
        rows = [_make_row(_dt(2026, 2, 24), summary="Quick check", action_items=[])]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=1))
        assert "Action:" not in result, (
            f"'Action:' should not appear when action_items is empty: {result!r}"
        )

    def test_no_action_in_line_when_none_items(self):
        """When action_items is None, 'Action:' must NOT appear in output."""
        rows = [_make_row(_dt(2026, 2, 24), summary="Quick check", action_items=None)]
        pool = _make_pool(rows)
        engine = ConversationReplayEngine(postgres_pool=pool)
        result = run(engine.get_recent_summary(n=1))
        assert "Action:" not in result, (
            f"'Action:' should not appear when action_items is None: {result!r}"
        )
