#!/usr/bin/env python3
"""
Tests for Story 4.01: BinduHydrator — Base Class + Session Init
AIVA RLM Nexus PRD v2 — Track A

Black box tests (BB1-BB3): verify public API behaviour from the outside.
White box tests (WB1-WB4): verify internal invariants, class constants,
                            Redis command choice, and failure resilience.

ALL tests use mocks — no real Redis connection required.
"""
import asyncio
import sys
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

sys.path.insert(0, "/mnt/e/genesis-system")

from core.hydrators import BinduHydrator, HydrationSession
from core.hydrators.bindu_hydrator import BinduHydrator as BinduHydratorDirect


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_redis_mock() -> AsyncMock:
    """Return an AsyncMock that simulates a successful redis.setex call."""
    mock = AsyncMock()
    mock.setex = AsyncMock(return_value=True)
    return mock


def run(coro):
    """Run a coroutine synchronously (Python 3.10-safe)."""
    return asyncio.get_event_loop().run_until_complete(coro)


# ---------------------------------------------------------------------------
# BB1: SETEX called with correct key, value, and TTL
# ---------------------------------------------------------------------------


class TestBB1_SetexCalledCorrectly:
    """BB1: start_hydration → Redis SETEX with key 'aiva:context:{session_id}',
    value 'pending', TTL 300."""

    def test_setex_called_with_correct_key(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        run(hydrator.start_hydration("s1", "c1"))

        redis.setex.assert_called_once()
        args = redis.setex.call_args[0]  # positional args
        assert args[0] == "aiva:context:s1", (
            f"Expected key 'aiva:context:s1', got '{args[0]}'"
        )

    def test_setex_called_with_pending_value(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        run(hydrator.start_hydration("s1", "c1"))

        args = redis.setex.call_args[0]
        assert args[2] == "pending", (
            f"Expected value 'pending', got '{args[2]}'"
        )

    def test_setex_called_with_ttl_300(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        run(hydrator.start_hydration("s1", "c1"))

        args = redis.setex.call_args[0]
        assert args[1] == 300, (
            f"Expected TTL 300s, got {args[1]}"
        )

    def test_setex_key_uses_session_id(self):
        """Different session_ids produce different Redis keys."""
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        run(hydrator.start_hydration("abc-123", "call-xyz"))

        args = redis.setex.call_args[0]
        assert args[0] == "aiva:context:abc-123"


# ---------------------------------------------------------------------------
# BB2: Returned HydrationSession.status == "pending"
# ---------------------------------------------------------------------------


class TestBB2_StatusPending:
    """BB2: The returned HydrationSession always has status='pending'."""

    def test_returned_status_is_pending(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        session = run(hydrator.start_hydration("s2", "c2"))

        assert session.status == "pending", (
            f"Expected status 'pending', got '{session.status}'"
        )

    def test_returned_type_is_hydration_session(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        session = run(hydrator.start_hydration("s2", "c2"))

        assert isinstance(session, HydrationSession), (
            f"Expected HydrationSession, got {type(session)}"
        )


# ---------------------------------------------------------------------------
# BB3: tasks_dispatched == 0, tasks_completed == 0
# ---------------------------------------------------------------------------


class TestBB3_TaskCountersZero:
    """BB3: Fresh session has both task counters at zero."""

    def test_tasks_dispatched_zero(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        session = run(hydrator.start_hydration("s3", "c3"))

        assert session.tasks_dispatched == 0, (
            f"Expected tasks_dispatched=0, got {session.tasks_dispatched}"
        )

    def test_tasks_completed_zero(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        session = run(hydrator.start_hydration("s3", "c3"))

        assert session.tasks_completed == 0, (
            f"Expected tasks_completed=0, got {session.tasks_completed}"
        )

    def test_session_id_and_call_id_preserved(self):
        """session_id and aiva_call_id must match what was passed in."""
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        session = run(hydrator.start_hydration("my-session", "my-call"))

        assert session.session_id == "my-session"
        assert session.aiva_call_id == "my-call"


# ---------------------------------------------------------------------------
# WB1: started_at is a UTC datetime (not None)
# ---------------------------------------------------------------------------


class TestWB1_StartedAtUTC:
    """WB1: HydrationSession.started_at is a timezone-aware UTC datetime."""

    def test_started_at_is_not_none(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        session = run(hydrator.start_hydration("s4", "c4"))

        assert session.started_at is not None

    def test_started_at_is_datetime(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        session = run(hydrator.start_hydration("s4", "c4"))

        assert isinstance(session.started_at, datetime), (
            f"Expected datetime, got {type(session.started_at)}"
        )

    def test_started_at_is_timezone_aware(self):
        """Datetime must carry tzinfo (UTC) — no naive datetimes."""
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        session = run(hydrator.start_hydration("s4", "c4"))

        assert session.started_at.tzinfo is not None, (
            "started_at must be timezone-aware (UTC)"
        )

    def test_started_at_is_utc(self):
        """tzinfo must resolve to UTC offset 0."""
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        session = run(hydrator.start_hydration("s4", "c4"))

        # UTC offset must be zero
        assert session.started_at.utcoffset().total_seconds() == 0, (
            f"Expected UTC (offset 0), got {session.started_at.tzinfo}"
        )


# ---------------------------------------------------------------------------
# WB2: HYDRATION_DEADLINE_MS == 500 class constant
# ---------------------------------------------------------------------------


class TestWB2_DeadlineConstant:
    """WB2: BinduHydrator.HYDRATION_DEADLINE_MS == 500."""

    def test_deadline_constant_value(self):
        assert BinduHydrator.HYDRATION_DEADLINE_MS == 500, (
            f"Expected HYDRATION_DEADLINE_MS=500, got {BinduHydrator.HYDRATION_DEADLINE_MS}"
        )

    def test_deadline_constant_accessible_on_instance(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)
        assert hydrator.HYDRATION_DEADLINE_MS == 500


# ---------------------------------------------------------------------------
# WB3: SETEX used (not SET without TTL)
# ---------------------------------------------------------------------------


class TestWB3_SetexNotSet:
    """WB3: The implementation must use setex (atomic SET+EXPIRE), not plain set."""

    def test_set_not_called(self):
        """redis.set must NOT be called — only redis.setex."""
        redis = _make_redis_mock()
        redis.set = AsyncMock()  # add a spy on .set
        hydrator = BinduHydrator(redis_client=redis)

        run(hydrator.start_hydration("s5", "c5"))

        redis.set.assert_not_called()

    def test_setex_called_exactly_once(self):
        redis = _make_redis_mock()
        hydrator = BinduHydrator(redis_client=redis)

        run(hydrator.start_hydration("s5", "c5"))

        assert redis.setex.call_count == 1, (
            f"setex should be called exactly once, called {redis.setex.call_count} times"
        )


# ---------------------------------------------------------------------------
# WB4: Redis failure → HydrationSession still returned (non-fatal)
# ---------------------------------------------------------------------------


class TestWB4_RedisFaultTolerance:
    """WB4: If Redis raises an exception, start_hydration still returns a valid
    HydrationSession — Redis failure must never crash the call flow."""

    def test_redis_error_returns_session(self):
        redis = AsyncMock()
        redis.setex = AsyncMock(side_effect=ConnectionError("Redis down"))

        hydrator = BinduHydrator(redis_client=redis)

        # Should NOT raise — must return session despite Redis failure
        session = run(hydrator.start_hydration("s6", "c6"))

        assert session is not None
        assert isinstance(session, HydrationSession)

    def test_redis_error_session_has_correct_status(self):
        """Even after Redis failure, session.status must still be 'pending'."""
        redis = AsyncMock()
        redis.setex = AsyncMock(side_effect=OSError("Timeout"))

        hydrator = BinduHydrator(redis_client=redis)
        session = run(hydrator.start_hydration("s6", "c6"))

        assert session.status == "pending"

    def test_redis_error_session_has_zero_counters(self):
        """Counters remain zero even after Redis failure."""
        redis = AsyncMock()
        redis.setex = AsyncMock(side_effect=RuntimeError("Connection refused"))

        hydrator = BinduHydrator(redis_client=redis)
        session = run(hydrator.start_hydration("s6", "c6"))

        assert session.tasks_dispatched == 0
        assert session.tasks_completed == 0

    def test_redis_timeout_non_fatal(self):
        """TimeoutError from Redis must also be swallowed."""
        redis = AsyncMock()
        redis.setex = AsyncMock(side_effect=TimeoutError("SETEX timed out"))

        hydrator = BinduHydrator(redis_client=redis)

        # Must not raise
        session = run(hydrator.start_hydration("s6-timeout", "c6"))
        assert isinstance(session, HydrationSession)


# ---------------------------------------------------------------------------
# Package export tests — verify __init__.py exports
# ---------------------------------------------------------------------------


class TestPackageExports:
    """Verify that core.hydrators exports both classes correctly."""

    def test_hydration_session_importable_from_package(self):
        from core.hydrators import HydrationSession as HS
        assert HS is HydrationSession

    def test_bindu_hydrator_importable_from_package(self):
        from core.hydrators import BinduHydrator as BH
        assert BH is BinduHydratorDirect

    def test_hydration_session_dataclass_fields(self):
        """Verify all 6 required fields exist on the dataclass."""
        import dataclasses
        field_names = {f.name for f in dataclasses.fields(HydrationSession)}
        required = {
            "session_id",
            "aiva_call_id",
            "started_at",
            "status",
            "tasks_dispatched",
            "tasks_completed",
        }
        assert required == field_names, (
            f"Missing fields: {required - field_names}"
        )


# ---------------------------------------------------------------------------
# Run summary
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    result = pytest.main([__file__, "-v", "--tb=short"])
    sys.exit(result)
