"""
Story 9.02 — Test Suite
========================
NightlyEpochRunner: Conversation Aggregator

File under test: core/epoch/nightly_epoch_runner.py

BB Tests (5):
  BB1: 5 conversations returned from mock cursor → all 5 in result as dicts
  BB2: Query uses INTERVAL '7 days' (verify via cursor.execute call args)
  BB3: participants->>'kinan' = 'true' in query (verify via execute args)
  BB4: Empty cursor result → empty list returned
  BB5: pg_conn is None → empty list returned immediately (no cursor call)

WB Tests (4):
  WB1: Postgres INTERVAL used (not Python timedelta in query string)
  WB2: ORDER BY started_at ASC in query
  WB3: cursor.close() called after fetchall
  WB4: Exception during execute → empty list returned (not raised)

All tests use MagicMock — zero live Postgres I/O.
"""
from __future__ import annotations

import asyncio
import sys
from unittest.mock import MagicMock, call

import pytest

sys.path.insert(0, "/mnt/e/genesis-system")

from core.epoch.nightly_epoch_runner import (
    AGGREGATION_QUERY,
    NightlyEpochRunner,
    _COLUMNS,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _arun(coro):
    """Run a coroutine synchronously using asyncio.run()."""
    return asyncio.run(coro)


def _make_rows(n: int) -> list[tuple]:
    """
    Build *n* fake DB rows that match the AGGREGATION_QUERY column order:
        conversation_id, started_at, transcript_raw, enriched_entities,
        decisions_made, action_items, key_facts, kinan_directives
    """
    return [
        (
            f"conv-{i:04d}",           # conversation_id
            f"2026-02-{i + 1:02d}",    # started_at (fake ISO string)
            f"transcript text {i}",    # transcript_raw
            {"entity": i},             # enriched_entities
            [f"decision-{i}"],         # decisions_made
            [f"action-{i}"],           # action_items
            [f"fact-{i}"],             # key_facts
            [f"directive-{i}"],        # kinan_directives
        )
        for i in range(n)
    ]


def _make_pg_conn(rows: list[tuple]) -> MagicMock:
    """
    Return a mock Postgres connection whose cursor returns *rows* on fetchall.
    """
    cursor_mock = MagicMock()
    cursor_mock.fetchall.return_value = rows

    conn_mock = MagicMock()
    conn_mock.cursor.return_value = cursor_mock
    return conn_mock


# ---------------------------------------------------------------------------
# BB1 — 5 conversations returned as dicts
# ---------------------------------------------------------------------------


class TestBB1_FiveConversationsReturnedAsDicts:
    """BB1: aggregate_week() must return all rows as dicts with correct keys."""

    def test_five_rows_returned_as_list_of_dicts(self):
        """BB1: 5 mock rows → list of 5 dicts."""
        rows = _make_rows(5)
        pg_conn = _make_pg_conn(rows)
        runner = NightlyEpochRunner(pg_conn=pg_conn)

        result = _arun(runner.aggregate_week())

        assert isinstance(result, list), f"Expected list, got {type(result)}"
        assert len(result) == 5, f"Expected 5 items, got {len(result)}"

    def test_each_result_is_a_dict(self):
        """BB1: Every element in the result must be a dict."""
        rows = _make_rows(3)
        pg_conn = _make_pg_conn(rows)
        runner = NightlyEpochRunner(pg_conn=pg_conn)

        result = _arun(runner.aggregate_week())

        for i, item in enumerate(result):
            assert isinstance(item, dict), (
                f"Expected dict at index {i}, got {type(item)}"
            )

    def test_dicts_have_all_eight_keys(self):
        """BB1: Each dict must contain exactly the 8 expected column keys."""
        rows = _make_rows(2)
        pg_conn = _make_pg_conn(rows)
        runner = NightlyEpochRunner(pg_conn=pg_conn)

        result = _arun(runner.aggregate_week())

        expected_keys = set(_COLUMNS)
        for i, item in enumerate(result):
            assert set(item.keys()) == expected_keys, (
                f"Dict at index {i} has wrong keys: "
                f"got {set(item.keys())}, expected {expected_keys}"
            )

    def test_dict_values_match_row_data(self):
        """BB1: Dict values must correspond to the correct row columns."""
        rows = _make_rows(1)
        pg_conn = _make_pg_conn(rows)
        runner = NightlyEpochRunner(pg_conn=pg_conn)

        result = _arun(runner.aggregate_week())

        assert result[0]["conversation_id"] == "conv-0000"
        assert result[0]["transcript_raw"] == "transcript text 0"
        assert result[0]["key_facts"] == ["fact-0"]
        assert result[0]["kinan_directives"] == ["directive-0"]


# ---------------------------------------------------------------------------
# BB2 — Query contains INTERVAL '7 days'
# ---------------------------------------------------------------------------


class TestBB2_QueryContainsInterval7Days:
    """BB2: The SQL query must use INTERVAL '7 days' (server-side window)."""

    def test_interval_7_days_in_query_string(self):
        """BB2: AGGREGATION_QUERY must contain \"INTERVAL '7 days'\"."""
        assert "INTERVAL '7 days'" in AGGREGATION_QUERY, (
            "Expected AGGREGATION_QUERY to contain \"INTERVAL '7 days'\", "
            f"got:\n{AGGREGATION_QUERY}"
        )

    def test_query_passed_to_cursor_execute_contains_interval(self):
        """BB2: cursor.execute() is called with the AGGREGATION_QUERY constant."""
        rows = _make_rows(1)
        pg_conn = _make_pg_conn(rows)
        runner = NightlyEpochRunner(pg_conn=pg_conn)

        _arun(runner.aggregate_week())

        cursor_mock = pg_conn.cursor.return_value
        args, _ = cursor_mock.execute.call_args
        executed_sql = args[0]
        assert "INTERVAL '7 days'" in executed_sql, (
            "cursor.execute() was not called with INTERVAL '7 days' in the SQL"
        )


# ---------------------------------------------------------------------------
# BB3 — Query filters Kinan conversations
# ---------------------------------------------------------------------------


class TestBB3_QueryFiltersKinanConversations:
    """BB3: The SQL query must filter to Kinan-participant conversations."""

    def test_kinan_filter_in_query_string(self):
        """BB3: AGGREGATION_QUERY must contain the Kinan JSONB filter."""
        kinan_filter = "participants->>'kinan' = 'true'"
        assert kinan_filter in AGGREGATION_QUERY, (
            f"Expected AGGREGATION_QUERY to contain {kinan_filter!r}, "
            f"got:\n{AGGREGATION_QUERY}"
        )

    def test_kinan_filter_passed_to_cursor_execute(self):
        """BB3: cursor.execute() SQL must include the Kinan participants filter."""
        rows = _make_rows(2)
        pg_conn = _make_pg_conn(rows)
        runner = NightlyEpochRunner(pg_conn=pg_conn)

        _arun(runner.aggregate_week())

        cursor_mock = pg_conn.cursor.return_value
        args, _ = cursor_mock.execute.call_args
        executed_sql = args[0]
        assert "participants->>'kinan' = 'true'" in executed_sql, (
            "cursor.execute() SQL does not contain the Kinan participants filter"
        )


# ---------------------------------------------------------------------------
# BB4 — Empty cursor result → empty list returned
# ---------------------------------------------------------------------------


class TestBB4_EmptyCursorReturnsEmptyList:
    """BB4: When the database returns zero rows, aggregate_week() returns []."""

    def test_empty_fetchall_returns_empty_list(self):
        """BB4: fetchall() → [] means aggregate_week() returns []."""
        pg_conn = _make_pg_conn(rows=[])
        runner = NightlyEpochRunner(pg_conn=pg_conn)

        result = _arun(runner.aggregate_week())

        assert result == [], (
            f"Expected empty list when no rows found, got {result!r}"
        )

    def test_empty_result_is_list_not_none(self):
        """BB4: Return value must be a list, not None, even when empty."""
        pg_conn = _make_pg_conn(rows=[])
        runner = NightlyEpochRunner(pg_conn=pg_conn)

        result = _arun(runner.aggregate_week())

        assert result is not None, "aggregate_week() must never return None"
        assert isinstance(result, list), (
            f"Expected list, got {type(result)!r}"
        )


# ---------------------------------------------------------------------------
# BB5 — pg_conn is None → empty list returned immediately
# ---------------------------------------------------------------------------


class TestBB5_NoneConnReturnsEmptyListImmediately:
    """BB5: pg_conn=None must return [] without calling any cursor method."""

    def test_none_conn_returns_empty_list(self):
        """BB5: aggregate_week() returns [] when pg_conn is None."""
        runner = NightlyEpochRunner(pg_conn=None)

        result = _arun(runner.aggregate_week())

        assert result == [], (
            f"Expected [] when pg_conn is None, got {result!r}"
        )

    def test_none_conn_never_calls_cursor(self):
        """BB5: No cursor is ever created when pg_conn is None."""
        # Use a MagicMock so we can confirm .cursor() is never called
        spy_conn = MagicMock()
        runner = NightlyEpochRunner(pg_conn=None)
        # Override pg to our spy (still None logic path — pg is set to None)
        # Confirm explicitly: pg_conn=None means runner.pg is None
        assert runner.pg is None

        result = _arun(runner.aggregate_week())

        # spy_conn is not stored on runner — confirm runner.pg is None
        assert result == []
        spy_conn.cursor.assert_not_called()

    def test_none_conn_result_is_list_not_none(self):
        """BB5: Return value must be a list instance, not None."""
        runner = NightlyEpochRunner(pg_conn=None)

        result = _arun(runner.aggregate_week())

        assert isinstance(result, list), (
            f"Expected list instance, got {type(result)!r}"
        )


# ---------------------------------------------------------------------------
# WB1 — Postgres INTERVAL used (not Python timedelta)
# ---------------------------------------------------------------------------


class TestWB1_PostgresIntervalNotPythonTimedelta:
    """WB1: The 7-day window must use SQL INTERVAL, not Python datetime."""

    def test_no_timedelta_import_in_query_module(self):
        """WB1: The nightly_epoch_runner module must not import datetime.timedelta."""
        import core.epoch.nightly_epoch_runner as mod
        import inspect
        src = inspect.getsource(mod)
        # We allow 'datetime' in imports (e.g. for type hints) but the QUERY
        # itself must not contain Python datetime arithmetic.
        assert "timedelta" not in src, (
            "nightly_epoch_runner.py must not use timedelta — use SQL INTERVAL instead"
        )

    def test_interval_string_is_correct_form(self):
        """WB1: SQL must use literal string \"INTERVAL '7 days'\" (not parameterised)."""
        # The interval must be a SQL literal, not a %s placeholder
        assert "INTERVAL '7 days'" in AGGREGATION_QUERY
        # Confirm there's no '%s' near the interval (it must be a literal)
        lines = AGGREGATION_QUERY.splitlines()
        for line in lines:
            if "INTERVAL" in line:
                assert "%s" not in line, (
                    "INTERVAL must be a SQL literal, not a parameterised value"
                )


# ---------------------------------------------------------------------------
# WB2 — ORDER BY started_at ASC in query
# ---------------------------------------------------------------------------


class TestWB2_OrderByStartedAtAsc:
    """WB2: The SQL query must order results by started_at ASC (oldest first)."""

    def test_order_by_started_at_asc_in_query(self):
        """WB2: AGGREGATION_QUERY must contain ORDER BY started_at ASC."""
        assert "ORDER BY started_at ASC" in AGGREGATION_QUERY, (
            "Expected AGGREGATION_QUERY to contain 'ORDER BY started_at ASC', "
            f"got:\n{AGGREGATION_QUERY}"
        )

    def test_asc_not_desc_ordering(self):
        """WB2: Descending sort must not be used (we want oldest-first)."""
        # If DESC appears in the ORDER BY line that's a bug
        lines = AGGREGATION_QUERY.splitlines()
        for line in lines:
            if "ORDER BY" in line:
                assert "DESC" not in line, (
                    "ORDER BY must be ASC (oldest first), not DESC"
                )


# ---------------------------------------------------------------------------
# WB3 — cursor.close() called after fetchall
# ---------------------------------------------------------------------------


class TestWB3_CursorClosedAfterFetchall:
    """WB3: cursor.close() must be called after fetchall() to release resources."""

    def test_cursor_close_called_on_success(self):
        """WB3: cursor.close() is called after successful fetchall."""
        rows = _make_rows(3)
        pg_conn = _make_pg_conn(rows)
        runner = NightlyEpochRunner(pg_conn=pg_conn)

        _arun(runner.aggregate_week())

        cursor_mock = pg_conn.cursor.return_value
        cursor_mock.close.assert_called_once()

    def test_fetchall_called_before_close(self):
        """WB3: fetchall() must happen before close() (correct order)."""
        rows = _make_rows(1)
        pg_conn = _make_pg_conn(rows)

        # Track call order
        call_log: list[str] = []
        cursor_mock = pg_conn.cursor.return_value

        original_fetchall = cursor_mock.fetchall
        original_close = cursor_mock.close

        cursor_mock.fetchall.side_effect = lambda: call_log.append("fetchall") or rows
        cursor_mock.close.side_effect = lambda: call_log.append("close")

        runner = NightlyEpochRunner(pg_conn=pg_conn)
        _arun(runner.aggregate_week())

        assert call_log.index("fetchall") < call_log.index("close"), (
            f"fetchall must be called before close, got order: {call_log}"
        )


# ---------------------------------------------------------------------------
# WB4 — Exception during execute → empty list returned
# ---------------------------------------------------------------------------


class TestWB4_ExceptionReturnsEmptyList:
    """WB4: Database errors must be caught and return [] (not re-raised)."""

    def test_execute_raises_returns_empty_list(self):
        """WB4: cursor.execute() raising an exception → aggregate_week returns []."""
        conn_mock = MagicMock()
        cursor_mock = MagicMock()
        cursor_mock.execute.side_effect = RuntimeError("connection refused")
        conn_mock.cursor.return_value = cursor_mock

        runner = NightlyEpochRunner(pg_conn=conn_mock)
        result = _arun(runner.aggregate_week())

        assert result == [], (
            f"Expected [] on database error, got {result!r}"
        )

    def test_fetchall_raises_returns_empty_list(self):
        """WB4: cursor.fetchall() raising an exception → aggregate_week returns []."""
        conn_mock = MagicMock()
        cursor_mock = MagicMock()
        cursor_mock.fetchall.side_effect = Exception("fetchall failed")
        conn_mock.cursor.return_value = cursor_mock

        runner = NightlyEpochRunner(pg_conn=conn_mock)
        result = _arun(runner.aggregate_week())

        assert result == [], (
            f"Expected [] on fetchall error, got {result!r}"
        )

    def test_exception_does_not_propagate(self):
        """WB4: No exception must propagate out of aggregate_week() on DB error."""
        conn_mock = MagicMock()
        cursor_mock = MagicMock()
        cursor_mock.execute.side_effect = Exception("boom")
        conn_mock.cursor.return_value = cursor_mock

        runner = NightlyEpochRunner(pg_conn=conn_mock)

        # Must not raise
        try:
            result = _arun(runner.aggregate_week())
        except Exception as exc:
            pytest.fail(
                f"aggregate_week() must not propagate exceptions, but raised: {exc}"
            )

    def test_return_value_is_list_not_none_on_error(self):
        """WB4: Return value is list (not None) even on database error."""
        conn_mock = MagicMock()
        cursor_mock = MagicMock()
        cursor_mock.execute.side_effect = RuntimeError("DB down")
        conn_mock.cursor.return_value = cursor_mock

        runner = NightlyEpochRunner(pg_conn=conn_mock)
        result = _arun(runner.aggregate_week())

        assert result is not None, "aggregate_week() must never return None"
        assert isinstance(result, list), (
            f"Expected list on error, got {type(result)!r}"
        )


# ---------------------------------------------------------------------------
# Run summary
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    result = pytest.main([__file__, "-v", "--tb=short"])
    sys.exit(result)
