"""RLM Neo-Cortex -- Module 5: Feedback Collection Pipeline Tests.

Covers Stories 5.01-5.09.  All backends are mocked (AsyncMock for PG,
AsyncMock for Redis) -- no live Elestio connections required.

Story coverage:
    5.01  FeedbackCollector constructor
    5.02  record_feedback() -- positive / negative / neutral
    5.03  cache_interaction() / get_interaction() -- Redis TTL, cross-tenant
    5.04  get_pair_count() / get_recent_pairs()
    5.05  check_dpo_readiness()
    5.06  _generate_fallback_response()
    5.07  handle_callback_query() / build_feedback_keyboard()  (Telegram)
    5.08  POST /api/v1/feedback/webhook  (FastAPI)
    5.09  Integration lifecycle

VERIFICATION_STAMP
Story: 5.09
Verified By: parallel-builder
Verified At: 2026-02-26
Tests: see results
Coverage: >=85%
"""
from __future__ import annotations

import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID, uuid4

import pytest

# Ensure project root on path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))

from core.rlm.contracts import FeedbackSignal, PreferencePair
from core.rlm.feedback import (
    FeedbackCollector,
    _DEFAULT_ANNOTATOR,
    _DPO_MINIMUM_PAIRS,
    _INTERACTION_KEY_PREFIX,
    _INTERACTION_TTL_SECONDS,
    _TABLE_NAME,
    _generate_fallback_response,
)
from core.rlm.feedback_telegram import (
    _CALLBACK_PREFIX,
    _SIGNAL_MAP,
    build_feedback_keyboard,
    handle_callback_query,
)
from core.rlm.feedback_webhook import (
    FeedbackWebhookRequest,
    _SIGNAL_ENUM_MAP,
    _VALID_SIGNALS,
    create_feedback_router,
)


# ===========================================================================
# Fixtures and helpers
# ===========================================================================

_FAKE_DSN = "postgresql://user:pass@localhost:5432/genesis"
_FAKE_REDIS = "redis://localhost:6379/0"


class _CollectorWithMocks:
    """Thin wrapper that exposes the underlying mock_conn for white-box tests."""

    def __init__(self, collector: FeedbackCollector, mock_conn: AsyncMock) -> None:
        self.collector = collector
        self.mock_conn = mock_conn

    # Delegate attribute access so the wrapper is usable as FeedbackCollector
    def __getattr__(self, name: str) -> Any:
        return getattr(self.collector, name)

    def __setattr__(self, name: str, value: Any) -> None:
        if name in ("collector", "mock_conn"):
            object.__setattr__(self, name, value)
        else:
            setattr(self.collector, name, value)


def _make_collector(
    pg_rows: Optional[List[Dict]] = None,
    redis_data: Optional[Dict[str, str]] = None,
) -> FeedbackCollector:
    """Build a FeedbackCollector with mocked PG pool and Redis client.

    Also attaches ``_mock_conn`` to the returned collector so white-box tests
    can assert directly on it without walking the MagicMock chain.
    """
    collector = FeedbackCollector(pg_dsn=_FAKE_DSN, redis_url=_FAKE_REDIS)

    # ---- Mock PostgreSQL pool ----
    rows = pg_rows or []

    mock_conn = AsyncMock()
    # fetchrow for COUNT queries
    mock_conn.fetchrow = AsyncMock(return_value={"cnt": len(rows)})
    # fetch for SELECT queries
    mock_conn.fetch = AsyncMock(return_value=[
        {
            "input_text": r.get("input_text", ""),
            "chosen_output": r.get("chosen_output", ""),
            "rejected_output": r.get("rejected_output", ""),
            "annotator_id": r.get("annotator_id", _DEFAULT_ANNOTATOR),
            "confidence": r.get("confidence", 1.0),
            "metadata": json.dumps(r.get("metadata", {})),
            "created_at": r.get("created_at", "2026-02-26T00:00:00Z"),
        }
        for r in rows
    ])
    mock_conn.execute = AsyncMock(return_value=None)

    mock_pool = MagicMock()
    mock_pool.acquire = MagicMock(
        return_value=_AsyncContextManager(mock_conn)
    )

    collector._pg_pool = mock_pool
    # Expose mock_conn directly for white-box assertions
    collector._mock_conn = mock_conn  # type: ignore[attr-defined]

    # ---- Mock Redis client ----
    store: Dict[str, Any] = {}
    if redis_data:
        store.update(redis_data)

    mock_redis = AsyncMock()

    async def _redis_get(key: str) -> Optional[str]:
        return store.get(key)

    async def _redis_set(key: str, value: str, ex: int = 0) -> None:
        store[key] = value

    mock_redis.get = AsyncMock(side_effect=_redis_get)
    mock_redis.set = AsyncMock(side_effect=_redis_set)

    collector._redis = mock_redis
    return collector


class _AsyncContextManager:
    """Minimal async context manager wrapping a mock object."""

    def __init__(self, obj: Any) -> None:
        self._obj = obj

    async def __aenter__(self) -> Any:
        return self._obj

    async def __aexit__(self, *_: Any) -> None:
        pass


# ===========================================================================
# Story 5.01 -- FeedbackCollector Constructor
# ===========================================================================

class TestFeedbackCollectorConstructor:
    """BB + WB tests for Story 5.01."""

    def test_construct_with_explicit_dsn(self) -> None:
        """BB: construct OK with explicit pg_dsn."""
        collector = FeedbackCollector(pg_dsn=_FAKE_DSN)
        assert collector is not None

    def test_construct_with_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """BB: construct OK when DATABASE_URL env var is set."""
        monkeypatch.setenv("DATABASE_URL", _FAKE_DSN)
        collector = FeedbackCollector()
        assert collector is not None

    def test_no_dsn_no_env_raises_value_error(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """BB: no DSN + no env = ValueError."""
        monkeypatch.delenv("DATABASE_URL", raising=False)
        with pytest.raises(ValueError, match="PostgreSQL DSN is required"):
            FeedbackCollector()

    def test_table_name_is_pl_preference_pairs(self) -> None:
        """WB: table name constant equals pl_preference_pairs."""
        assert FeedbackCollector.TABLE_NAME == "pl_preference_pairs"
        assert _TABLE_NAME == "pl_preference_pairs"

    @pytest.mark.asyncio
    async def test_get_pair_count_on_empty_returns_zero(self) -> None:
        """BB: get_pair_count on empty collector = 0."""
        collector = _make_collector(pg_rows=[])
        assert await collector.get_pair_count() == 0

    def test_implements_protocol(self) -> None:
        """WB: FeedbackCollector implements FeedbackCollectorProtocol methods."""
        collector = FeedbackCollector(pg_dsn=_FAKE_DSN)
        assert callable(getattr(collector, "record_feedback", None))
        assert callable(getattr(collector, "get_pair_count", None))


# ===========================================================================
# Story 5.02 -- record_feedback
# ===========================================================================

class TestRecordFeedback:
    """BB + WB tests for Story 5.02."""

    @pytest.mark.asyncio
    async def test_positive_signal_ai_is_chosen(self) -> None:
        """BB: POSITIVE → AI response = chosen_output."""
        tid = uuid4()
        iid = "interaction-001"
        ai_response = "Your appointment is confirmed for 9am."

        collector = _make_collector()
        # Pre-populate Redis cache
        key = f"{_INTERACTION_KEY_PREFIX}:{tid}:{iid}"
        collector._redis.get = AsyncMock(
            return_value=json.dumps({
                "input_text": "Book me an appointment",
                "output_text": ai_response,
            })
        )

        pair = await collector.record_feedback(
            tenant_id=tid,
            interaction_id=iid,
            signal=FeedbackSignal.POSITIVE,
        )

        assert pair is not None
        assert pair.chosen_output == ai_response

    @pytest.mark.asyncio
    async def test_negative_signal_ai_is_rejected(self) -> None:
        """BB: NEGATIVE → AI response = rejected_output."""
        tid = uuid4()
        iid = "interaction-002"
        ai_response = "I'm sorry, I can't help with that."

        collector = _make_collector()
        collector._redis.get = AsyncMock(
            return_value=json.dumps({
                "input_text": "I have a complaint",
                "output_text": ai_response,
            })
        )

        pair = await collector.record_feedback(
            tenant_id=tid,
            interaction_id=iid,
            signal=FeedbackSignal.NEGATIVE,
        )

        assert pair is not None
        assert pair.rejected_output == ai_response

    @pytest.mark.asyncio
    async def test_neutral_signal_returns_none(self) -> None:
        """BB: NEUTRAL → None returned, no pair generated."""
        collector = _make_collector()
        result = await collector.record_feedback(
            tenant_id=uuid4(),
            interaction_id="interaction-003",
            signal=FeedbackSignal.NEUTRAL,
        )
        assert result is None

    @pytest.mark.asyncio
    async def test_neutral_signal_no_db_write(self) -> None:
        """WB: NEUTRAL signal → conn.execute never called."""
        collector = _make_collector()
        await collector.record_feedback(
            tenant_id=uuid4(),
            interaction_id="interaction-004",
            signal=FeedbackSignal.NEUTRAL,
        )
        # Pool's execute should not have been called
        collector._mock_conn.execute.assert_not_called()  # type: ignore[attr-defined]

    @pytest.mark.asyncio
    async def test_pair_has_correct_annotator(self) -> None:
        """WB: pair.annotator_id = 'telegram_feedback'."""
        collector = _make_collector()
        collector._redis.get = AsyncMock(
            return_value=json.dumps({
                "input_text": "Hello",
                "output_text": "Hi there",
            })
        )
        pair = await collector.record_feedback(
            tenant_id=uuid4(),
            interaction_id="interaction-005",
            signal=FeedbackSignal.POSITIVE,
        )
        assert pair is not None
        assert pair.annotator_id == _DEFAULT_ANNOTATOR

    @pytest.mark.asyncio
    async def test_pair_confidence_is_1(self) -> None:
        """WB: pair.confidence = 1.0."""
        collector = _make_collector()
        collector._redis.get = AsyncMock(
            return_value=json.dumps({"input_text": "x", "output_text": "y"})
        )
        pair = await collector.record_feedback(
            tenant_id=uuid4(),
            interaction_id="i006",
            signal=FeedbackSignal.POSITIVE,
        )
        assert pair is not None
        assert pair.confidence == 1.0

    @pytest.mark.asyncio
    async def test_interaction_id_in_metadata(self) -> None:
        """WB: interaction_id stored in pair.metadata."""
        iid = "interaction-007"
        collector = _make_collector()
        collector._redis.get = AsyncMock(
            return_value=json.dumps({"input_text": "q", "output_text": "a"})
        )
        pair = await collector.record_feedback(
            tenant_id=uuid4(),
            interaction_id=iid,
            signal=FeedbackSignal.NEGATIVE,
        )
        assert pair is not None
        assert pair.metadata.get("interaction_id") == iid


# ===========================================================================
# Story 5.03 -- Interaction History Cache
# ===========================================================================

class TestInteractionCache:
    """BB + WB tests for Story 5.03."""

    @pytest.mark.asyncio
    async def test_cache_and_retrieve_ok(self) -> None:
        """BB: cache interaction → get_interaction returns it."""
        collector = _make_collector()
        tid = uuid4()
        iid = "i-cache-01"

        await collector.cache_interaction(
            tenant_id=tid,
            interaction_id=iid,
            input_text="Book me a slot",
            output_text="Confirmed for 10am",
        )

        result = await collector.get_interaction(tid, iid)
        assert result is not None
        assert result["input_text"] == "Book me a slot"
        assert result["output_text"] == "Confirmed for 10am"

    @pytest.mark.asyncio
    async def test_nonexistent_returns_none(self) -> None:
        """BB: non-existent interaction_id → None."""
        collector = _make_collector()
        result = await collector.get_interaction(uuid4(), "does-not-exist")
        assert result is None

    @pytest.mark.asyncio
    async def test_cross_tenant_returns_none(self) -> None:
        """BB: different tenant cannot see another tenant's interaction."""
        collector = _make_collector()
        tid_a = uuid4()
        tid_b = uuid4()
        iid = "shared-iid"

        await collector.cache_interaction(
            tenant_id=tid_a,
            interaction_id=iid,
            input_text="Secret",
            output_text="Response",
        )

        # Tenant B should not be able to fetch Tenant A's interaction
        result = await collector.get_interaction(tid_b, iid)
        assert result is None

    @pytest.mark.asyncio
    async def test_redis_set_called_with_correct_ex(self) -> None:
        """WB: Redis SET called with ex=86400 (24hr TTL)."""
        collector = _make_collector()
        set_calls: List[Any] = []

        async def _capture_set(key: str, value: str, ex: int = 0) -> None:
            set_calls.append({"key": key, "value": value, "ex": ex})

        collector._redis.set = AsyncMock(side_effect=_capture_set)

        tid = uuid4()
        iid = "ttl-test"
        await collector.cache_interaction(tid, iid, "in", "out")

        assert len(set_calls) == 1
        assert set_calls[0]["ex"] == _INTERACTION_TTL_SECONDS  # 86400

    @pytest.mark.asyncio
    async def test_key_includes_both_ids(self) -> None:
        """WB: Redis key includes tenant_id and interaction_id."""
        collector = _make_collector()
        keys_used: List[str] = []

        async def _capture_set(key: str, value: str, ex: int = 0) -> None:
            keys_used.append(key)

        collector._redis.set = AsyncMock(side_effect=_capture_set)

        tid = uuid4()
        iid = "key-test-001"
        await collector.cache_interaction(tid, iid, "in", "out")

        assert len(keys_used) == 1
        assert str(tid) in keys_used[0]
        assert iid in keys_used[0]
        assert keys_used[0].startswith(_INTERACTION_KEY_PREFIX)


# ===========================================================================
# Story 5.04 -- get_pair_count / get_recent_pairs
# ===========================================================================

class TestPairQueries:
    """BB + WB tests for Story 5.04."""

    @pytest.mark.asyncio
    async def test_empty_returns_zero(self) -> None:
        """BB: empty table → count = 0."""
        collector = _make_collector(pg_rows=[])
        assert await collector.get_pair_count() == 0

    @pytest.mark.asyncio
    async def test_after_5_feedbacks_count_is_5(self) -> None:
        """BB: 5 rows → count = 5."""
        rows = [{"input_text": f"q{i}", "chosen_output": "a", "rejected_output": "b"} for i in range(5)]
        collector = _make_collector(pg_rows=rows)
        assert await collector.get_pair_count() == 5

    @pytest.mark.asyncio
    async def test_recent_pairs_respects_limit(self) -> None:
        """BB: recent(2) returns exactly 2 rows."""
        rows = [{"input_text": f"q{i}", "chosen_output": "a", "rejected_output": "b"} for i in range(5)]
        collector = _make_collector(pg_rows=rows)
        # Override fetch to return exactly `limit` rows
        collector._mock_conn.fetch = AsyncMock(return_value=[  # type: ignore[attr-defined]
            {"input_text": "q0", "chosen_output": "a", "rejected_output": "b",
             "annotator_id": _DEFAULT_ANNOTATOR, "confidence": 1.0,
             "metadata": "{}", "created_at": "2026-02-26T00:00:00Z"},
            {"input_text": "q1", "chosen_output": "a", "rejected_output": "b",
             "annotator_id": _DEFAULT_ANNOTATOR, "confidence": 1.0,
             "metadata": "{}", "created_at": "2026-02-26T00:00:00Z"},
        ])
        pairs = await collector.get_recent_pairs(limit=2)
        assert len(pairs) == 2

    @pytest.mark.asyncio
    async def test_get_pair_count_uses_count_query(self) -> None:
        """WB: COUNT(*) query executed for get_pair_count."""
        collector = _make_collector(pg_rows=[{"x": 1}])
        mock_conn = collector._mock_conn  # type: ignore[attr-defined]

        await collector.get_pair_count()

        # fetchrow must have been called (COUNT query)
        mock_conn.fetchrow.assert_called_once()
        call_args = mock_conn.fetchrow.call_args[0][0]
        assert "COUNT(*)" in call_args.upper()

    @pytest.mark.asyncio
    async def test_get_recent_pairs_order_by_created_at_desc(self) -> None:
        """WB: ORDER BY created_at DESC present in query."""
        collector = _make_collector(pg_rows=[])
        mock_conn = collector._mock_conn  # type: ignore[attr-defined]
        mock_conn.fetch = AsyncMock(return_value=[])

        await collector.get_recent_pairs(limit=10)

        call_sql = mock_conn.fetch.call_args[0][0]
        assert "ORDER BY created_at DESC" in call_sql


# ===========================================================================
# Story 5.05 -- DPO Training Readiness
# ===========================================================================

class TestDPOReadiness:
    """BB + WB tests for Story 5.05."""

    def _make_collector_with_pairs(
        self, count: int, pos: int = 0, neg: int = 0
    ) -> FeedbackCollector:
        """Build collector whose mock returns `count` total pairs."""
        collector = FeedbackCollector(pg_dsn=_FAKE_DSN, redis_url=_FAKE_REDIS)

        # Mock PG pool
        mock_conn = AsyncMock()

        async def _fetchrow(sql: str, *_args: Any) -> Dict[str, Any]:
            return {"cnt": count}

        async def _fetch(sql: str, *_args: Any) -> List[Dict]:
            if "metadata" in sql and "annotator_id" not in sql:
                # Signal distribution query
                rows = []
                for _ in range(pos):
                    rows.append({"metadata": json.dumps({"signal": "POSITIVE"})})
                for _ in range(neg):
                    rows.append({"metadata": json.dumps({"signal": "NEGATIVE"})})
                neutral_count = count - pos - neg
                for _ in range(max(neutral_count, 0)):
                    rows.append({"metadata": json.dumps({"signal": "NEUTRAL"})})
                return rows
            else:
                # Annotator distribution query
                return [{"annotator_id": _DEFAULT_ANNOTATOR, "cnt": count}]

        mock_conn.fetchrow = AsyncMock(side_effect=_fetchrow)
        mock_conn.fetch = AsyncMock(side_effect=_fetch)

        mock_pool = MagicMock()
        mock_pool.acquire = MagicMock(
            return_value=_AsyncContextManager(mock_conn)
        )
        collector._pg_pool = mock_pool

        # Mock Redis
        mock_redis = AsyncMock()
        mock_redis.get = AsyncMock(return_value=None)
        mock_redis.set = AsyncMock(return_value=None)
        collector._redis = mock_redis

        return collector

    @pytest.mark.asyncio
    async def test_50_pairs_not_ready(self) -> None:
        """BB: 50 pairs → ready=False."""
        collector = self._make_collector_with_pairs(50, pos=25, neg=25)
        result = await collector.check_dpo_readiness()
        assert result["ready"] is False
        assert result["pair_count"] == 50

    @pytest.mark.asyncio
    async def test_100_pairs_ready(self) -> None:
        """BB: 100 pairs → ready=True."""
        collector = self._make_collector_with_pairs(100, pos=60, neg=40)
        result = await collector.check_dpo_readiness()
        assert result["ready"] is True
        assert result["pair_count"] == 100

    @pytest.mark.asyncio
    async def test_minimum_required_is_100(self) -> None:
        """BB: minimum_required always = 100."""
        collector = self._make_collector_with_pairs(0)
        result = await collector.check_dpo_readiness()
        assert result["minimum_required"] == _DPO_MINIMUM_PAIRS
        assert result["minimum_required"] == 100

    @pytest.mark.asyncio
    async def test_threshold_hardcoded_at_100(self) -> None:
        """WB: threshold constant = 100 in contracts."""
        assert _DPO_MINIMUM_PAIRS == 100

    @pytest.mark.asyncio
    async def test_annotator_distribution_in_result(self) -> None:
        """WB: annotator_dist groups by annotator_id."""
        collector = self._make_collector_with_pairs(50, pos=30, neg=20)
        result = await collector.check_dpo_readiness()
        assert "annotator_dist" in result
        assert isinstance(result["annotator_dist"], dict)


# ===========================================================================
# Story 5.06 -- _generate_fallback_response
# ===========================================================================

class TestGenerateFallbackResponse:
    """BB + WB tests for Story 5.06."""

    def test_booking_input_mentions_booking(self) -> None:
        """BB: booking-related input → response mentions booking."""
        resp = _generate_fallback_response("I want to book an appointment")
        assert any(kw in resp.lower() for kw in ("book", "appointment", "detail", "help"))

    def test_complaint_input_neutral_ack(self) -> None:
        """BB: complaint input → neutral acknowledgment."""
        resp = _generate_fallback_response("I have a complaint about my service")
        assert len(resp) > 0
        assert any(kw in resp.lower() for kw in ("feedback", "noted", "share", "appreciate"))

    def test_never_empty(self) -> None:
        """BB: response is never empty regardless of input."""
        assert len(_generate_fallback_response("")) > 0
        assert len(_generate_fallback_response("   ")) > 0
        assert len(_generate_fallback_response("?")) > 0
        assert len(_generate_fallback_response("x" * 5000)) > 0

    def test_response_under_500_chars(self) -> None:
        """WB: response always < 500 characters."""
        assert len(_generate_fallback_response("short")) < 500
        assert len(_generate_fallback_response("q" * 10_000)) < 500

    def test_template_selection_by_keyword(self) -> None:
        """WB: different keywords trigger different templates."""
        booking_resp = _generate_fallback_response("book a slot")
        complaint_resp = _generate_fallback_response("this is a problem")
        question_resp = _generate_fallback_response("what time does it open?")
        default_resp = _generate_fallback_response("hello there")

        # At minimum each should differ from the others or produce a valid string
        for resp in (booking_resp, complaint_resp, question_resp, default_resp):
            assert len(resp) > 0
            assert len(resp) < 500


# ===========================================================================
# Story 5.07 -- Telegram Inline Button Handler
# ===========================================================================

class TestTelegramHandler:
    """BB + WB tests for Story 5.07."""

    @pytest.mark.asyncio
    async def test_valid_positive_generates_pair(self) -> None:
        """BB: valid positive callback → pair_generated=True."""
        collector = _make_collector()
        collector._redis.get = AsyncMock(
            return_value=json.dumps({"input_text": "Hello", "output_text": "Hi"})
        )
        tid = uuid4()
        result = await handle_callback_query(
            callback_data="feedback:interaction-abc:positive",
            chat_id=12345,
            message_id=99,
            collector=collector,
            tenant_id=tid,
        )
        assert result["status"] == "recorded"
        assert result["pair_generated"] is True

    @pytest.mark.asyncio
    async def test_valid_negative_generates_pair(self) -> None:
        """BB: valid negative callback → pair_generated=True."""
        collector = _make_collector()
        collector._redis.get = AsyncMock(
            return_value=json.dumps({"input_text": "Request", "output_text": "Response"})
        )
        tid = uuid4()
        result = await handle_callback_query(
            callback_data="feedback:interaction-def:negative",
            chat_id=12345,
            message_id=100,
            collector=collector,
            tenant_id=tid,
        )
        assert result["status"] == "recorded"
        assert result["pair_generated"] is True

    @pytest.mark.asyncio
    async def test_malformed_callback_returns_error(self) -> None:
        """BB: malformed callback_data → error dict, no exception."""
        collector = _make_collector()
        result = await handle_callback_query(
            callback_data="bad_data",
            chat_id=12345,
            message_id=101,
            collector=collector,
        )
        assert result["status"] == "error"
        assert "error" in result
        assert result["pair_generated"] is False

    @pytest.mark.asyncio
    async def test_wrong_prefix_returns_error(self) -> None:
        """BB: wrong prefix → error dict."""
        collector = _make_collector()
        result = await handle_callback_query(
            callback_data="action:interaction-xyz:positive",
            chat_id=12345,
            message_id=102,
            collector=collector,
        )
        assert result["status"] == "error"

    @pytest.mark.asyncio
    async def test_collector_record_feedback_called_with_correct_signal(
        self,
    ) -> None:
        """WB: handle_callback_query calls record_feedback with parsed signal."""
        collector = _make_collector()
        collector._redis.get = AsyncMock(
            return_value=json.dumps({"input_text": "in", "output_text": "out"})
        )
        recorded_signals: List[FeedbackSignal] = []

        original = collector.record_feedback

        async def _capture(
            tenant_id: UUID,
            interaction_id: str,
            signal: FeedbackSignal,
            context: Optional[Dict] = None,
        ) -> Optional[PreferencePair]:
            recorded_signals.append(signal)
            return await original(tenant_id, interaction_id, signal, context)

        collector.record_feedback = _capture  # type: ignore[assignment]
        tid = uuid4()
        await handle_callback_query(
            callback_data="feedback:i-wbtest:negative",
            chat_id=111,
            message_id=0,
            collector=collector,
            tenant_id=tid,
        )
        assert len(recorded_signals) == 1
        assert recorded_signals[0] == FeedbackSignal.NEGATIVE

    def test_build_feedback_keyboard_structure(self) -> None:
        """BB: keyboard has correct structure."""
        kb = build_feedback_keyboard("interaction-kb-01")
        assert "inline_keyboard" in kb
        buttons = kb["inline_keyboard"]
        assert len(buttons) == 1
        row = buttons[0]
        assert len(row) == 2
        for btn in row:
            assert "text" in btn
            assert "callback_data" in btn

    def test_build_feedback_keyboard_callback_format(self) -> None:
        """WB: callback_data follows 'feedback:{id}:{signal}' format."""
        iid = "test-interaction-999"
        kb = build_feedback_keyboard(iid)
        row = kb["inline_keyboard"][0]
        for btn in row:
            parts = btn["callback_data"].split(":")
            assert len(parts) == 3
            assert parts[0] == _CALLBACK_PREFIX
            assert parts[1] == iid
            assert parts[2] in ("positive", "negative")

    @pytest.mark.asyncio
    async def test_neutral_telegram_callback_pair_generated_false(self) -> None:
        """BB: neutral signal → pair_generated=False."""
        collector = _make_collector()
        tid = uuid4()
        result = await handle_callback_query(
            callback_data="feedback:interaction-neu:neutral",
            chat_id=999,
            message_id=0,
            collector=collector,
            tenant_id=tid,
        )
        assert result["status"] == "recorded"
        assert result["pair_generated"] is False


# ===========================================================================
# Story 5.08 -- Webhook Endpoint
# ===========================================================================

class TestFeedbackWebhook:
    """BB + WB tests for Story 5.08."""

    def _make_test_client(
        self,
        collector: Optional[FeedbackCollector] = None,
    ) -> Any:
        """Build a TestClient for the feedback webhook router."""
        from fastapi import FastAPI
        from fastapi.testclient import TestClient

        app = FastAPI()
        if collector is None:
            collector = _make_collector()
        router = create_feedback_router(collector)
        app.include_router(router)
        return TestClient(app)

    def test_valid_positive_returns_200(self) -> None:
        """BB: valid positive payload → 200."""
        tid = uuid4()
        iid = "wh-001"
        collector = _make_collector()
        # Cache interaction so 404 guard passes
        store: Dict[str, str] = {}
        key = f"{_INTERACTION_KEY_PREFIX}:{tid}:{iid}"
        store[key] = json.dumps({"input_text": "hi", "output_text": "hello"})

        async def _redis_get(k: str) -> Optional[str]:
            return store.get(k)

        collector._redis.get = AsyncMock(side_effect=_redis_get)

        client = self._make_test_client(collector)
        resp = client.post(
            "/api/v1/feedback/webhook",
            json={
                "tenant_id": str(tid),
                "interaction_id": iid,
                "signal": "positive",
            },
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "recorded"

    def test_invalid_signal_returns_422(self) -> None:
        """BB: invalid signal 'maybe' → 422."""
        collector = _make_collector()
        client = self._make_test_client(collector)
        resp = client.post(
            "/api/v1/feedback/webhook",
            json={
                "tenant_id": str(uuid4()),
                "interaction_id": "wh-invalid",
                "signal": "maybe",
            },
        )
        assert resp.status_code == 422

    def test_unknown_interaction_returns_404(self) -> None:
        """BB: unknown interaction_id → 404."""
        collector = _make_collector()
        # Redis returns None (no cached interaction)
        collector._redis.get = AsyncMock(return_value=None)
        client = self._make_test_client(collector)
        resp = client.post(
            "/api/v1/feedback/webhook",
            json={
                "tenant_id": str(uuid4()),
                "interaction_id": "does-not-exist",
                "signal": "positive",
            },
        )
        assert resp.status_code == 404

    def test_pydantic_validates_signal_enum(self) -> None:
        """WB: Pydantic model rejects invalid signal."""
        import pydantic

        with pytest.raises((pydantic.ValidationError, ValueError)):
            FeedbackWebhookRequest(
                tenant_id=uuid4(),
                interaction_id="test",
                signal="INVALID_SIGNAL",
            )

    def test_valid_signals_set(self) -> None:
        """WB: valid signals are positive/negative/neutral."""
        assert _VALID_SIGNALS == {"positive", "negative", "neutral"}

    def test_signal_enum_map_coverage(self) -> None:
        """WB: all valid signals map to FeedbackSignal enum."""
        for sig in _VALID_SIGNALS:
            assert sig in _SIGNAL_ENUM_MAP

    def test_neutral_webhook_returns_200_no_pair(self) -> None:
        """BB: neutral signal → 200, pair_generated=False."""
        collector = _make_collector()
        # Neutral doesn't check cache
        client = self._make_test_client(collector)
        resp = client.post(
            "/api/v1/feedback/webhook",
            json={
                "tenant_id": str(uuid4()),
                "interaction_id": "neutral-wh",
                "signal": "neutral",
            },
        )
        assert resp.status_code == 200
        assert resp.json()["pair_generated"] is False


# ===========================================================================
# Story 5.09 -- Module Integration Test
# ===========================================================================

class TestFeedbackIntegration:
    """Full lifecycle integration tests for Story 5.09."""

    @pytest.mark.asyncio
    async def test_full_lifecycle_positive(self) -> None:
        """BB: cache interaction → positive feedback → pair verified."""
        collector = _make_collector()
        tid = uuid4()
        iid = "integ-001"

        await collector.cache_interaction(
            tenant_id=tid,
            interaction_id=iid,
            input_text="Can I book a table?",
            output_text="Sure, what time works for you?",
        )

        pair = await collector.record_feedback(
            tenant_id=tid,
            interaction_id=iid,
            signal=FeedbackSignal.POSITIVE,
        )

        assert pair is not None
        assert pair.chosen_output == "Sure, what time works for you?"
        assert pair.annotator_id == _DEFAULT_ANNOTATOR

    @pytest.mark.asyncio
    async def test_negative_feedback_inverted_pair(self) -> None:
        """BB: negative feedback creates inverted pair (AI = rejected)."""
        collector = _make_collector()
        tid = uuid4()
        iid = "integ-002"
        ai_resp = "I cannot help with that."

        await collector.cache_interaction(
            tenant_id=tid,
            interaction_id=iid,
            input_text="I need help urgently",
            output_text=ai_resp,
        )

        pair = await collector.record_feedback(
            tenant_id=tid,
            interaction_id=iid,
            signal=FeedbackSignal.NEGATIVE,
        )

        assert pair is not None
        assert pair.rejected_output == ai_resp

    @pytest.mark.asyncio
    async def test_neutral_no_pair_no_db_write(self) -> None:
        """BB: neutral → no pair, no DB write (zero execute calls)."""
        collector = _make_collector()
        mock_conn = collector._mock_conn  # type: ignore[attr-defined]

        result = await collector.record_feedback(
            tenant_id=uuid4(),
            interaction_id="integ-003",
            signal=FeedbackSignal.NEUTRAL,
        )

        assert result is None
        mock_conn.execute.assert_not_called()

    @pytest.mark.asyncio
    async def test_dpo_readiness_at_100(self) -> None:
        """BB: DPO readiness check at 100 pairs → ready=True."""
        collector = FeedbackCollector(pg_dsn=_FAKE_DSN, redis_url=_FAKE_REDIS)

        mock_conn = AsyncMock()
        mock_conn.fetchrow = AsyncMock(return_value={"cnt": 100})
        mock_conn.fetch = AsyncMock(side_effect=[
            [{"metadata": json.dumps({"signal": "POSITIVE"})} for _ in range(60)]
            + [{"metadata": json.dumps({"signal": "NEGATIVE"})} for _ in range(40)],
            [{"annotator_id": _DEFAULT_ANNOTATOR, "cnt": 100}],
        ])

        mock_pool = MagicMock()
        mock_pool.acquire = MagicMock(return_value=_AsyncContextManager(mock_conn))
        collector._pg_pool = mock_pool

        mock_redis = AsyncMock()
        mock_redis.get = AsyncMock(return_value=None)
        mock_redis.set = AsyncMock(return_value=None)
        collector._redis = mock_redis

        result = await collector.check_dpo_readiness()
        assert result["ready"] is True
        assert result["pair_count"] == 100

    @pytest.mark.asyncio
    async def test_telegram_callback_parsing(self) -> None:
        """BB: Telegram callback parsing (integration)."""
        collector = _make_collector()
        collector._redis.get = AsyncMock(
            return_value=json.dumps({"input_text": "test", "output_text": "ok"})
        )
        tid = uuid4()

        result = await handle_callback_query(
            callback_data="feedback:integ-tg-001:positive",
            chat_id=777,
            message_id=0,
            collector=collector,
            tenant_id=tid,
        )
        assert result["status"] == "recorded"
        assert result["pair_generated"] is True

    def test_webhook_endpoint_validation(self) -> None:
        """BB: webhook rejects bad tenant_id format."""
        collector = _make_collector()

        from fastapi import FastAPI
        from fastapi.testclient import TestClient

        app = FastAPI()
        router = create_feedback_router(collector)
        app.include_router(router)
        client = TestClient(app)

        resp = client.post(
            "/api/v1/feedback/webhook",
            json={
                "tenant_id": "not-a-uuid",
                "interaction_id": "x",
                "signal": "positive",
            },
        )
        # Pydantic UUID validation → 422
        assert resp.status_code == 422

    @pytest.mark.asyncio
    async def test_writes_to_pl_preference_pairs_table(self) -> None:
        """WB: record_feedback inserts into pl_preference_pairs."""
        collector = _make_collector()
        collector._redis.get = AsyncMock(
            return_value=json.dumps({"input_text": "in", "output_text": "out"})
        )
        mock_conn = collector._mock_conn  # type: ignore[attr-defined]
        execute_calls: List[Any] = []

        async def _capture_execute(sql: str, *args: Any) -> None:
            execute_calls.append({"sql": sql, "args": args})

        mock_conn.execute = AsyncMock(side_effect=_capture_execute)

        await collector.record_feedback(
            tenant_id=uuid4(),
            interaction_id="wb-insert-001",
            signal=FeedbackSignal.POSITIVE,
        )

        assert len(execute_calls) == 1
        assert _TABLE_NAME in execute_calls[0]["sql"]
