#!/usr/bin/env python3
"""
Tests for Story 5.03 (Track B): EventSourcingStream — Immutable Event Append + Replay

Black Box tests (BB): verify the public contract from the caller's perspective —
    replay returns events in version order, version filtering works,
    state folding is correct, empty sessions return empty/neutral values.

White Box tests (WB): verify internals — dual-write to ColdLedger + Redis,
    Redis failure is non-fatal (event still in ColdLedger), sequential
    application of events in get_current_state.

ALL tests use mocks — NO real Postgres, NO real Redis.

Story: 5.03
File under test: core/storage/event_sourcing.py
"""

from __future__ import annotations

import sys
sys.path.insert(0, "/mnt/e/genesis-system")

import json
import logging
import uuid
from datetime import datetime
from unittest.mock import MagicMock, call, patch

import pytest

# ---------------------------------------------------------------------------
# Module under test
# ---------------------------------------------------------------------------

from core.storage.event_sourcing import EventSourcingStream, GenesisEvent, REDIS_STREAM_KEY
from core.storage import EventSourcingStream as ESS_from_pkg
from core.storage import GenesisEvent as GE_from_pkg


# ---------------------------------------------------------------------------
# Shared test helpers
# ---------------------------------------------------------------------------

SESSION_ID = "aaaaaaaa-aaaa-4aaa-baaa-aaaaaaaaaaaa"


def _make_event(
    version: int,
    event_type: str = "state_update",
    payload: dict | None = None,
    session_id: str = SESSION_ID,
) -> GenesisEvent:
    """Build a GenesisEvent with sane defaults."""
    return GenesisEvent(
        id=str(uuid.uuid4()),
        session_id=session_id,
        event_type=event_type,
        payload=payload or {"key": f"value_v{version}"},
        version=version,
        created_at=datetime(2026, 2, 25, 10, version, 0),
    )


def _make_mock_ledger(rows: list[dict] | None = None) -> MagicMock:
    """Return a mock ColdLedger whose get_events returns ``rows``."""
    ledger = MagicMock()
    ledger.write_event.return_value = str(uuid.uuid4())
    ledger.get_events.return_value = rows if rows is not None else []
    return ledger


def _event_to_ledger_row(event: GenesisEvent) -> dict:
    """Simulate the round-trip through ColdLedger — produce the row that
    get_events would return after append() stored the enriched payload."""
    enriched = {
        "event_id": event.id,
        "version": event.version,
        "created_at": event.created_at.isoformat(),
        **event.payload,
    }
    return {
        "id": str(uuid.uuid4()),
        "session_id": event.session_id,
        "event_type": event.event_type,
        "payload": enriched,
        "created_at": event.created_at,
    }


def _make_stream(rows: list[dict] | None = None, redis=None) -> EventSourcingStream:
    """Return a stream wired to a mock ledger and optional mock redis."""
    ledger = _make_mock_ledger(rows)
    stream = EventSourcingStream(cold_ledger=ledger, redis_client=redis)
    stream._mock_ledger = ledger
    return stream


# ===========================================================================
# BB1: Append 3 events → replay returns all 3 in version order
# ===========================================================================

class TestBB1ReplayVersionOrder:
    """BB1: append 3 events, replay returns all 3 in ascending version order."""

    def _rows_out_of_order(self):
        """Rows returned by get_events in a scrambled order (simulates DB ordering by time)."""
        e1 = _make_event(version=1, payload={"a": 1})
        e3 = _make_event(version=3, payload={"c": 3})
        e2 = _make_event(version=2, payload={"b": 2})
        # Return rows in version order 3, 1, 2 — stream must sort them
        return [
            _event_to_ledger_row(e3),
            _event_to_ledger_row(e1),
            _event_to_ledger_row(e2),
        ]

    def test_replay_returns_three_events(self):
        stream = _make_stream(rows=self._rows_out_of_order())
        result = stream.replay(SESSION_ID)
        assert len(result) == 3

    def test_replay_events_are_genesis_event_instances(self):
        stream = _make_stream(rows=self._rows_out_of_order())
        result = stream.replay(SESSION_ID)
        for ev in result:
            assert isinstance(ev, GenesisEvent)

    def test_replay_ordered_by_version_ascending(self):
        stream = _make_stream(rows=self._rows_out_of_order())
        result = stream.replay(SESSION_ID)
        versions = [e.version for e in result]
        assert versions == sorted(versions), f"Expected ascending versions, got {versions}"
        assert versions == [1, 2, 3]

    def test_replay_calls_ledger_get_events_with_session_id(self):
        stream = _make_stream(rows=[])
        stream.replay(SESSION_ID)
        stream._mock_ledger.get_events.assert_called_once_with(SESSION_ID)


# ===========================================================================
# BB2: replay_from_version filters correctly
# ===========================================================================

class TestBB2ReplayFromVersion:
    """BB2: replay_from_version returns only events with version >= from_version."""

    def _five_rows(self):
        return [_event_to_ledger_row(_make_event(version=v)) for v in range(1, 6)]

    def test_from_version_1_returns_all_five(self):
        stream = _make_stream(rows=self._five_rows())
        result = stream.replay_from_version(SESSION_ID, from_version=1)
        assert len(result) == 5

    def test_from_version_3_returns_three(self):
        stream = _make_stream(rows=self._five_rows())
        result = stream.replay_from_version(SESSION_ID, from_version=3)
        assert len(result) == 3
        assert all(e.version >= 3 for e in result)

    def test_from_version_5_returns_one(self):
        stream = _make_stream(rows=self._five_rows())
        result = stream.replay_from_version(SESSION_ID, from_version=5)
        assert len(result) == 1
        assert result[0].version == 5

    def test_from_version_beyond_max_returns_empty(self):
        stream = _make_stream(rows=self._five_rows())
        result = stream.replay_from_version(SESSION_ID, from_version=99)
        assert result == []

    def test_result_still_ordered_by_version(self):
        stream = _make_stream(rows=self._five_rows())
        result = stream.replay_from_version(SESSION_ID, from_version=2)
        versions = [e.version for e in result]
        assert versions == sorted(versions)


# ===========================================================================
# BB3: get_current_state folds events correctly (later events override earlier)
# ===========================================================================

class TestBB3GetCurrentStateFolding:
    """BB3: get_current_state folds events; later versions override earlier on key collision."""

    def test_single_event_state(self):
        ev = _make_event(version=1, payload={"status": "initialised"})
        stream = _make_stream(rows=[_event_to_ledger_row(ev)])
        state = stream.get_current_state(SESSION_ID)
        assert state.get("status") == "initialised"

    def test_later_event_overrides_key(self):
        ev1 = _make_event(version=1, payload={"status": "initialised"})
        ev2 = _make_event(version=2, payload={"status": "running"})
        stream = _make_stream(rows=[
            _event_to_ledger_row(ev1),
            _event_to_ledger_row(ev2),
        ])
        state = stream.get_current_state(SESSION_ID)
        assert state["status"] == "running", "version 2 must override version 1"

    def test_non_overlapping_keys_merged(self):
        ev1 = _make_event(version=1, payload={"agent": "forge"})
        ev2 = _make_event(version=2, payload={"task": "build"})
        stream = _make_stream(rows=[
            _event_to_ledger_row(ev1),
            _event_to_ledger_row(ev2),
        ])
        state = stream.get_current_state(SESSION_ID)
        assert state.get("agent") == "forge"
        assert state.get("task") == "build"

    def test_three_events_sequential_override(self):
        ev1 = _make_event(version=1, payload={"counter": 1, "tag": "alpha"})
        ev2 = _make_event(version=2, payload={"counter": 2})
        ev3 = _make_event(version=3, payload={"counter": 3, "done": True})
        # Intentionally pass rows out of version order — stream must sort before folding
        stream = _make_stream(rows=[
            _event_to_ledger_row(ev3),
            _event_to_ledger_row(ev1),
            _event_to_ledger_row(ev2),
        ])
        state = stream.get_current_state(SESSION_ID)
        assert state["counter"] == 3, "Final version (3) must win"
        assert state["tag"] == "alpha", "Key from version 1 survives if not overridden"
        assert state["done"] is True

    def test_bookkeeping_keys_stripped_from_state(self):
        """Internal keys added by append() must NOT appear in get_current_state output."""
        ev = _make_event(version=1, payload={"real_key": "real_value"})
        stream = _make_stream(rows=[_event_to_ledger_row(ev)])
        state = stream.get_current_state(SESSION_ID)
        assert "event_id" not in state, "event_id must be stripped from user-facing state"
        assert "version" not in state, "version must be stripped from user-facing state"
        assert "created_at" not in state, "created_at must be stripped from user-facing state"


# ===========================================================================
# BB4: Empty session → replay returns [], get_current_state returns {}
# ===========================================================================

class TestBB4EmptySession:
    """BB4: unknown/empty session → neutral return values, no exceptions."""

    def test_replay_empty_returns_empty_list(self):
        stream = _make_stream(rows=[])
        result = stream.replay(SESSION_ID)
        assert result == []

    def test_replay_from_version_empty_returns_empty_list(self):
        stream = _make_stream(rows=[])
        result = stream.replay_from_version(SESSION_ID, from_version=1)
        assert result == []

    def test_get_current_state_empty_returns_empty_dict(self):
        stream = _make_stream(rows=[])
        state = stream.get_current_state(SESSION_ID)
        assert state == {}

    def test_no_exception_on_empty_session(self):
        stream = _make_stream(rows=[])
        try:
            stream.replay(SESSION_ID)
            stream.replay_from_version(SESSION_ID, 1)
            stream.get_current_state(SESSION_ID)
        except Exception as exc:
            pytest.fail(f"Empty session raised unexpectedly: {exc}")


# ===========================================================================
# WB1: append writes to BOTH ColdLedger and Redis Stream (dual-write)
# ===========================================================================

class TestWB1DualWrite:
    """WB1: append() writes to ColdLedger AND publishes to Redis via XADD."""

    def test_append_calls_ledger_write_event(self):
        mock_ledger = _make_mock_ledger()
        stream = EventSourcingStream(cold_ledger=mock_ledger)
        ev = _make_event(version=1)
        stream.append(ev)
        mock_ledger.write_event.assert_called_once()

    def test_append_passes_correct_session_and_type_to_ledger(self):
        mock_ledger = _make_mock_ledger()
        stream = EventSourcingStream(cold_ledger=mock_ledger)
        ev = _make_event(version=1, event_type="task_complete")
        stream.append(ev)
        call_kwargs = mock_ledger.write_event.call_args
        # write_event(session_id=..., event_type=..., payload=...)
        args, kwargs = call_kwargs
        # Support both positional and keyword call styles
        passed_session = kwargs.get("session_id") or args[0]
        passed_type = kwargs.get("event_type") or args[1]
        assert passed_session == SESSION_ID
        assert passed_type == "task_complete"

    def test_append_calls_redis_xadd(self):
        mock_redis = MagicMock()
        mock_ledger = _make_mock_ledger()
        stream = EventSourcingStream(cold_ledger=mock_ledger, redis_client=mock_redis)
        ev = _make_event(version=1)
        stream.append(ev)
        mock_redis.xadd.assert_called_once()

    def test_redis_xadd_uses_correct_stream_key(self):
        mock_redis = MagicMock()
        mock_ledger = _make_mock_ledger()
        stream = EventSourcingStream(cold_ledger=mock_ledger, redis_client=mock_redis)
        ev = _make_event(version=1)
        stream.append(ev)
        xadd_call = mock_redis.xadd.call_args
        stream_key = xadd_call[0][0]  # first positional arg
        assert stream_key == REDIS_STREAM_KEY

    def test_redis_xadd_message_contains_event_id(self):
        mock_redis = MagicMock()
        mock_ledger = _make_mock_ledger()
        stream = EventSourcingStream(cold_ledger=mock_ledger, redis_client=mock_redis)
        ev = _make_event(version=1)
        stream.append(ev)
        xadd_call = mock_redis.xadd.call_args
        message_dict = xadd_call[0][1]  # second positional arg = fields dict
        assert "event_id" in message_dict
        assert message_dict["event_id"] == ev.id

    def test_no_redis_no_xadd_called(self):
        """When redis_client is None, no Redis interaction occurs."""
        mock_ledger = _make_mock_ledger()
        stream = EventSourcingStream(cold_ledger=mock_ledger, redis_client=None)
        ev = _make_event(version=1)
        stream.append(ev)  # must not raise
        # Ledger write still happens
        mock_ledger.write_event.assert_called_once()


# ===========================================================================
# WB2: Redis XADD failure → event still in ColdLedger (non-fatal)
# ===========================================================================

class TestWB2RedisFailureNonFatal:
    """WB2: Redis XADD failure is logged and does NOT prevent ColdLedger persistence."""

    def test_redis_failure_does_not_raise(self):
        mock_redis = MagicMock()
        mock_redis.xadd.side_effect = ConnectionError("Redis unreachable")
        mock_ledger = _make_mock_ledger()
        stream = EventSourcingStream(cold_ledger=mock_ledger, redis_client=mock_redis)
        ev = _make_event(version=1)
        # Must not propagate the Redis error
        try:
            stream.append(ev)
        except ConnectionError:
            pytest.fail("append() must not raise when Redis XADD fails")

    def test_ledger_write_still_called_when_redis_fails(self):
        mock_redis = MagicMock()
        mock_redis.xadd.side_effect = RuntimeError("timeout")
        mock_ledger = _make_mock_ledger()
        stream = EventSourcingStream(cold_ledger=mock_ledger, redis_client=mock_redis)
        ev = _make_event(version=1)
        stream.append(ev)
        # ColdLedger write MUST have happened
        mock_ledger.write_event.assert_called_once()

    def test_redis_failure_is_logged(self, caplog):
        mock_redis = MagicMock()
        mock_redis.xadd.side_effect = Exception("connection reset")
        mock_ledger = _make_mock_ledger()
        stream = EventSourcingStream(cold_ledger=mock_ledger, redis_client=mock_redis)
        ev = _make_event(version=1)
        with caplog.at_level(logging.ERROR, logger="core.storage.event_sourcing"):
            stream.append(ev)
        assert len(caplog.records) >= 1, "A log record must be emitted on Redis failure"
        assert caplog.records[-1].levelno == logging.ERROR


# ===========================================================================
# WB3: get_current_state applies events sequentially (not all-at-once)
# ===========================================================================

class TestWB3SequentialApplication:
    """WB3: get_current_state applies events one at a time in version order."""

    def test_overwrite_only_after_version_1_applied(self):
        """version 1 sets key=A, version 2 sets key=B — final must be B."""
        ev1 = _make_event(version=1, payload={"key": "A", "only_in_v1": True})
        ev2 = _make_event(version=2, payload={"key": "B"})
        stream = _make_stream(rows=[
            _event_to_ledger_row(ev1),
            _event_to_ledger_row(ev2),
        ])
        state = stream.get_current_state(SESSION_ID)
        assert state["key"] == "B"
        assert state.get("only_in_v1") is True, "Key set only in v1 must survive"

    def test_intermediate_state_concept(self):
        """Three events accumulate additive keys one version at a time."""
        ev1 = _make_event(version=1, payload={"step": 1})
        ev2 = _make_event(version=2, payload={"step": 2, "milestone": "A"})
        ev3 = _make_event(version=3, payload={"step": 3})
        stream = _make_stream(rows=[
            _event_to_ledger_row(ev1),
            _event_to_ledger_row(ev2),
            _event_to_ledger_row(ev3),
        ])
        state = stream.get_current_state(SESSION_ID)
        assert state["step"] == 3
        assert state.get("milestone") == "A", "milestone set in v2 must survive v3"

    def test_empty_payloads_do_not_clear_state(self):
        """An event with an empty user payload must not remove existing state keys."""
        ev1 = _make_event(version=1, payload={"important": "data"})
        ev2 = _make_event(version=2, payload={})  # empty user payload
        stream = _make_stream(rows=[
            _event_to_ledger_row(ev1),
            _event_to_ledger_row(ev2),
        ])
        state = stream.get_current_state(SESSION_ID)
        assert state.get("important") == "data", "Empty payload must not wipe prior state"


# ===========================================================================
# GenesisEvent dataclass tests
# ===========================================================================

class TestGenesisEventDataclass:
    """GenesisEvent must be a proper dataclass with all 6 required fields."""

    def test_instantiation(self):
        ev = _make_event(version=1)
        assert isinstance(ev, GenesisEvent)

    def test_all_six_fields_present(self):
        ev = _make_event(version=7)
        assert hasattr(ev, "id")
        assert hasattr(ev, "session_id")
        assert hasattr(ev, "event_type")
        assert hasattr(ev, "payload")
        assert hasattr(ev, "version")
        assert hasattr(ev, "created_at")

    def test_field_types(self):
        ev = _make_event(version=1)
        assert isinstance(ev.id, str)
        assert isinstance(ev.session_id, str)
        assert isinstance(ev.event_type, str)
        assert isinstance(ev.payload, dict)
        assert isinstance(ev.version, int)
        assert isinstance(ev.created_at, datetime)


# ===========================================================================
# Package export tests
# ===========================================================================

class TestPackageExports:
    """EventSourcingStream and GenesisEvent must be importable from core.storage."""

    def test_event_sourcing_stream_importable(self):
        assert ESS_from_pkg is EventSourcingStream

    def test_genesis_event_importable(self):
        assert GE_from_pkg is GenesisEvent

    def test_all_includes_event_sourcing_stream(self):
        from core.storage import __all__
        assert "EventSourcingStream" in __all__

    def test_all_includes_genesis_event(self):
        from core.storage import __all__
        assert "GenesisEvent" in __all__


# ===========================================================================
# No-SQLite guard
# ===========================================================================

class TestNoSQLite:
    """event_sourcing.py must not import sqlite3 (Genesis Rule 7)."""

    def test_no_sqlite3_import_in_source(self):
        import pathlib
        source = pathlib.Path(
            "/mnt/e/genesis-system/core/storage/event_sourcing.py"
        ).read_text()
        assert "import sqlite3" not in source, (
            "event_sourcing.py must NOT import sqlite3 — Genesis Rule 7 (no SQLite)"
        )

    def test_sqlite3_not_in_module_namespace(self):
        import core.storage.event_sourcing as mod
        assert not hasattr(mod, "sqlite3")


# ===========================================================================
# Standalone runner (mirrors pytest output when run directly)
# ===========================================================================

if __name__ == "__main__":
    import traceback

    test_groups = [
        # BB tests
        ("BB1a: replay returns 3 events", TestBB1ReplayVersionOrder().test_replay_returns_three_events),
        ("BB1b: events are GenesisEvent instances", TestBB1ReplayVersionOrder().test_replay_events_are_genesis_event_instances),
        ("BB1c: events ordered by version ascending", TestBB1ReplayVersionOrder().test_replay_ordered_by_version_ascending),
        ("BB1d: replay calls get_events", TestBB1ReplayVersionOrder().test_replay_calls_ledger_get_events_with_session_id),
        ("BB2a: from_v=1 → all five", TestBB2ReplayFromVersion().test_from_version_1_returns_all_five),
        ("BB2b: from_v=3 → three", TestBB2ReplayFromVersion().test_from_version_3_returns_three),
        ("BB2c: from_v=5 → one", TestBB2ReplayFromVersion().test_from_version_5_returns_one),
        ("BB2d: from_v=99 → empty", TestBB2ReplayFromVersion().test_from_version_beyond_max_returns_empty),
        ("BB2e: result ordered by version", TestBB2ReplayFromVersion().test_result_still_ordered_by_version),
        ("BB3a: single event state", TestBB3GetCurrentStateFolding().test_single_event_state),
        ("BB3b: later event overrides key", TestBB3GetCurrentStateFolding().test_later_event_overrides_key),
        ("BB3c: non-overlapping keys merged", TestBB3GetCurrentStateFolding().test_non_overlapping_keys_merged),
        ("BB3d: three events sequential override", TestBB3GetCurrentStateFolding().test_three_events_sequential_override),
        ("BB3e: bookkeeping keys stripped", TestBB3GetCurrentStateFolding().test_bookkeeping_keys_stripped_from_state),
        ("BB4a: replay empty → []", TestBB4EmptySession().test_replay_empty_returns_empty_list),
        ("BB4b: replay_from_version empty → []", TestBB4EmptySession().test_replay_from_version_empty_returns_empty_list),
        ("BB4c: get_current_state empty → {}", TestBB4EmptySession().test_get_current_state_empty_returns_empty_dict),
        ("BB4d: no exception on empty session", TestBB4EmptySession().test_no_exception_on_empty_session),
        # WB tests
        ("WB1a: append calls ledger write_event", TestWB1DualWrite().test_append_calls_ledger_write_event),
        ("WB1b: correct session+type passed to ledger", TestWB1DualWrite().test_append_passes_correct_session_and_type_to_ledger),
        ("WB1c: append calls redis xadd", TestWB1DualWrite().test_append_calls_redis_xadd),
        ("WB1d: xadd uses correct stream key", TestWB1DualWrite().test_redis_xadd_uses_correct_stream_key),
        ("WB1e: xadd message contains event_id", TestWB1DualWrite().test_redis_xadd_message_contains_event_id),
        ("WB1f: no redis → no xadd", TestWB1DualWrite().test_no_redis_no_xadd_called),
        ("WB2a: redis failure does not raise", TestWB2RedisFailureNonFatal().test_redis_failure_does_not_raise),
        ("WB2b: ledger write survives redis failure", TestWB2RedisFailureNonFatal().test_ledger_write_still_called_when_redis_fails),
        ("WB3a: overwrite only after v1 applied", TestWB3SequentialApplication().test_overwrite_only_after_version_1_applied),
        ("WB3b: intermediate state concept", TestWB3SequentialApplication().test_intermediate_state_concept),
        ("WB3c: empty payloads do not clear state", TestWB3SequentialApplication().test_empty_payloads_do_not_clear_state),
        # Dataclass
        ("DC: GenesisEvent instantiation", TestGenesisEventDataclass().test_instantiation),
        ("DC: all 6 fields present", TestGenesisEventDataclass().test_all_six_fields_present),
        ("DC: correct field types", TestGenesisEventDataclass().test_field_types),
        # Package exports
        ("PKG: EventSourcingStream importable", TestPackageExports().test_event_sourcing_stream_importable),
        ("PKG: GenesisEvent importable", TestPackageExports().test_genesis_event_importable),
        ("PKG: __all__ has EventSourcingStream", TestPackageExports().test_all_includes_event_sourcing_stream),
        ("PKG: __all__ has GenesisEvent", TestPackageExports().test_all_includes_genesis_event),
        # No SQLite
        ("SQLITE: no import in source", TestNoSQLite().test_no_sqlite3_import_in_source),
        ("SQLITE: not in module namespace", TestNoSQLite().test_sqlite3_not_in_module_namespace),
    ]

    # WB2c (log test) requires caplog fixture — skip in standalone runner
    print("Note: WB2c (redis_failure_is_logged) requires pytest caplog — run via pytest for that test.\n")

    passed = 0
    failed = 0
    for name, fn in test_groups:
        try:
            fn()
            print(f"  [PASS] {name}")
            passed += 1
        except Exception as exc:
            print(f"  [FAIL] {name}: {exc}")
            traceback.print_exc()
            failed += 1

    total = passed + failed
    print(f"\n{passed}/{total} tests passed (standalone runner, excl. caplog test)")
    if failed == 0:
        print("ALL STANDALONE TESTS PASSED -- Story 5.03 (Track B): EventSourcingStream")
    else:
        sys.exit(1)
