#!/usr/bin/env python3
"""
Tests for Story 4.03: BinduHydrator — Postgres Scatter Task
AIVA RLM Nexus PRD v2 — Track A

Black box tests (BB1-BB3): public API behaviour — what comes out.
White box tests (WB1-WB5): internal structure — SQL, patterns, safety.
Package export test + No SQLite test.

ALL database calls are mocked — NO real Postgres connection.
"""
import asyncio
import sys
from unittest.mock import MagicMock, patch, call

import pytest

sys.path.insert(0, "/mnt/e/genesis-system")

from core.hydrators.bindu_hydrator import BinduHydrator


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_BASE_ROW = {
    "conversation_id": "conv-001",
    "started_at": "2026-02-25T10:00:00Z",
    "summary": "Reviewed RLM PRD",
    "decisions_made": ["Use psycopg2 pool", "LIMIT 1 in SQL"],
    "action_items": ["Deploy story 4.03", "Write tests"],
    "kinan_directives": ["No SQLite", "E: drive only"],
    "emotional_signal": "focused",
    "key_facts": {"session": 85},
}


def _make_pg_mock(fetchone_return=None, raise_on_getconn=False, raise_on_execute=False):
    """
    Build a mock psycopg2-pool-style postgres client.

    Exposes:
        pg.getconn() → mock_connection
        mock_connection.cursor(cursor_factory=...) → context-manager mock_cursor
        mock_cursor.fetchone() → fetchone_return
        pg.putconn(conn)

    Optionally raises exceptions at getconn or execute stage.
    """
    pg = MagicMock()

    if raise_on_getconn:
        pg.getconn.side_effect = Exception("Cannot acquire connection")
        return pg

    mock_conn = MagicMock()
    pg.getconn.return_value = mock_conn

    mock_cursor = MagicMock()
    mock_cursor.fetchone.return_value = fetchone_return

    if raise_on_execute:
        mock_cursor.execute.side_effect = Exception("Query failed")

    # Support `with conn.cursor(...) as cur:` context-manager pattern
    mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
    mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)

    return pg


def run(coro):
    """Run coroutine synchronously (Python 3.10-safe)."""
    return asyncio.get_event_loop().run_until_complete(coro)


# ---------------------------------------------------------------------------
# BB1: 3 Kinan conversations in DB → returns most recent (ordered)
# ---------------------------------------------------------------------------


class TestBB1_ReturnsRowWhenFound:
    """BB1: When Kinan conversations exist, returns the most recent as a dict."""

    def test_returns_dict_when_row_found(self):
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        redis = MagicMock()
        hydrator = BinduHydrator(redis_client=redis, postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert result is not None
        assert isinstance(result, dict)

    def test_conversation_id_correct(self):
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert result["conversation_id"] == "conv-001"

    def test_summary_correct(self):
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert result["summary"] == "Reviewed RLM PRD"

    def test_action_items_present(self):
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert "action_items" in result

    def test_kinan_directives_present(self):
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert "kinan_directives" in result


# ---------------------------------------------------------------------------
# BB2: 0 Kinan conversations → returns None (not exception)
# ---------------------------------------------------------------------------


class TestBB2_NoRowsReturnsNone:
    """BB2: When fetchone() returns None (empty table), returns None safely."""

    def test_no_rows_returns_none(self):
        pg = _make_pg_mock(fetchone_return=None)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert result is None

    def test_no_rows_does_not_raise(self):
        pg = _make_pg_mock(fetchone_return=None)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        try:
            result = run(hydrator._scatter_postgres_task())
        except Exception as exc:
            pytest.fail(f"_scatter_postgres_task raised unexpectedly: {exc}")

        assert result is None


# ---------------------------------------------------------------------------
# BB3: conversations without Kinan → excluded (fetchone returns None)
# ---------------------------------------------------------------------------


class TestBB3_NonKinanConversationsExcluded:
    """BB3: Non-Kinan conversations are excluded by the WHERE clause (verified via None return)."""

    def test_no_kinan_conversations_returns_none(self):
        # The SQL WHERE filters them out → fetchone returns None
        pg = _make_pg_mock(fetchone_return=None)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert result is None


# ---------------------------------------------------------------------------
# WB1: LIMIT 1 present in the SQL query string
# ---------------------------------------------------------------------------


class TestWB1_LimitOneInSQL:
    """WB1: The SQL must contain LIMIT 1 — not Python slicing."""

    def test_limit_1_in_sql(self):
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        run(hydrator._scatter_postgres_task())

        # Extract the SQL string passed to cursor.execute()
        mock_conn = pg.getconn.return_value
        mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
        assert mock_cursor.execute.called, "cursor.execute was not called"

        sql_arg = mock_cursor.execute.call_args[0][0]
        assert "LIMIT 1" in sql_arg.upper(), (
            f"Expected 'LIMIT 1' in SQL, got: {sql_arg!r}"
        )


# ---------------------------------------------------------------------------
# WB2: JSONB filter in SQL — participants->>'kinan' = %s
# ---------------------------------------------------------------------------


class TestWB2_JsonbFilterInSQL:
    """WB2: SQL must contain the JSONB filter for kinan=true participants."""

    def test_jsonb_filter_in_sql(self):
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        run(hydrator._scatter_postgres_task())

        mock_conn = pg.getconn.return_value
        mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
        sql_arg = mock_cursor.execute.call_args[0][0]

        # Must contain the JSONB operator for participants->>'kinan'
        assert "participants->>'kinan'" in sql_arg, (
            f"Expected JSONB filter \"participants->>'kinan'\" in SQL, got: {sql_arg!r}"
        )

    def test_param_value_is_true_string(self):
        """The parameter passed to execute must be the string 'true'."""
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        run(hydrator._scatter_postgres_task())

        mock_conn = pg.getconn.return_value
        mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
        params = mock_cursor.execute.call_args[0][1]

        assert "true" in params, (
            f"Expected parameter 'true' in execute params, got: {params!r}"
        )


# ---------------------------------------------------------------------------
# WB3: Returns dict type (not tuple or Row)
# ---------------------------------------------------------------------------


class TestWB3_ReturnsDictType:
    """WB3: The returned value must be a plain dict (not tuple or Row)."""

    def test_return_type_is_dict(self):
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert isinstance(result, dict), (
            f"Expected dict, got {type(result).__name__}"
        )

    def test_required_keys_present(self):
        """All 4 mandatory keys must be present in the result."""
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        for key in ("conversation_id", "summary", "action_items", "kinan_directives"):
            assert key in result, f"Missing required key: {key!r}"


# ---------------------------------------------------------------------------
# WB4: getconn/putconn called in try/finally pattern
# ---------------------------------------------------------------------------


class TestWB4_GetconnPutconnPattern:
    """WB4: getconn must always be followed by putconn, even on exception."""

    def test_putconn_called_on_success(self):
        pg = _make_pg_mock(fetchone_return=_BASE_ROW)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        run(hydrator._scatter_postgres_task())

        pg.putconn.assert_called_once()

    def test_putconn_called_on_execute_error(self):
        """Even when execute raises, putconn must still be called (finally block)."""
        pg = _make_pg_mock(raise_on_execute=True)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        # Must not raise
        result = run(hydrator._scatter_postgres_task())

        assert result is None
        pg.putconn.assert_called_once()

    def test_putconn_not_called_when_getconn_raises(self):
        """If getconn itself fails, putconn should NOT be called (conn is None)."""
        pg = _make_pg_mock(raise_on_getconn=True)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert result is None
        pg.putconn.assert_not_called()


# ---------------------------------------------------------------------------
# WB5: On DB exception → returns None (not raises)
# ---------------------------------------------------------------------------


class TestWB5_ExceptionReturnsNone:
    """WB5: Any DB exception must be swallowed; method returns None."""

    def test_getconn_exception_returns_none(self):
        pg = _make_pg_mock(raise_on_getconn=True)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert result is None

    def test_execute_exception_returns_none(self):
        pg = _make_pg_mock(raise_on_execute=True)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        result = run(hydrator._scatter_postgres_task())

        assert result is None

    def test_exception_does_not_propagate(self):
        pg = _make_pg_mock(raise_on_getconn=True)
        hydrator = BinduHydrator(redis_client=MagicMock(), postgres_client=pg)

        try:
            result = run(hydrator._scatter_postgres_task())
        except Exception as exc:
            pytest.fail(f"_scatter_postgres_task raised unexpectedly: {exc}")

        assert result is None


# ---------------------------------------------------------------------------
# Package export test
# ---------------------------------------------------------------------------


class TestPackageExport:
    """Verify _scatter_postgres_task is accessible from the module."""

    def test_method_exists_on_class(self):
        assert hasattr(BinduHydrator, "_scatter_postgres_task"), (
            "BinduHydrator must have _scatter_postgres_task method"
        )

    def test_method_is_callable(self):
        assert callable(BinduHydrator._scatter_postgres_task), (
            "_scatter_postgres_task must be callable"
        )


# ---------------------------------------------------------------------------
# No SQLite test
# ---------------------------------------------------------------------------


class TestNoSQLite:
    """Verify the implementation does not import sqlite3."""

    def test_no_sqlite_import_in_module(self):
        import importlib
        import core.hydrators.bindu_hydrator as mod

        assert "sqlite3" not in dir(mod), (
            "sqlite3 must NOT be imported in bindu_hydrator"
        )

    def test_no_sqlite_in_source(self):
        import inspect
        import core.hydrators.bindu_hydrator as mod

        source = inspect.getsource(mod)
        assert "import sqlite3" not in source, (
            "Found 'import sqlite3' in bindu_hydrator source — FORBIDDEN"
        )


# ---------------------------------------------------------------------------
# Run summary
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    result = pytest.main([__file__, "-v", "--tb=short"])
    sys.exit(result)
