"""LearningPropagationService coverage — stub OptimalService.

LearningPropagationService is logic on top of OptimalService (the RAG layer).
We unit-test the wiring with a stub that records calls and returns canned
SearchResult lists; OptimalService itself is exercised in its own integration
tests.
"""

from __future__ import annotations

from typing import Any
from uuid import uuid4

import pytest
from roboco.models.optimal import IndexType, SearchResult
from roboco.services.learning import (
    Learning,
    LearningNotification,
    LearningPropagationService,
    LearningScope,
    LearningType,
    RecordLearningParams,
    _LearningServiceHolder,
    get_learning_service,
)


class _StubOptimal:
    """Records calls so tests can assert wiring."""

    def __init__(self, results: list[SearchResult] | None = None) -> None:
        self.recorded: list[Any] = []
        self.searches: list[dict[str, Any]] = []
        self.search_learnings_calls: list[dict[str, Any]] = []
        self.results = results or []

    async def record_learning(self, params: Any) -> None:
        self.recorded.append(params)

    async def search(
        self, *, query: str, index_types: list[IndexType], top_k: int
    ) -> list[SearchResult]:
        self.searches.append(
            {"query": query, "index_types": index_types, "top_k": top_k}
        )
        return self.results

    async def search_learnings(self, *, query: str, top_k: int) -> list[SearchResult]:
        self.search_learnings_calls.append({"query": query, "top_k": top_k})
        return self.results


@pytest.fixture
def svc() -> LearningPropagationService:
    return LearningPropagationService()


@pytest.mark.asyncio
async def test_record_learning_requires_initialization(
    svc: LearningPropagationService,
) -> None:
    with pytest.raises(RuntimeError, match="not initialized"):
        await svc.record_learning(
            RecordLearningParams(
                agent_id=uuid4(),
                agent_role="developer",
                content="x",
                learning_type=LearningType.SOLUTION,
                scope=LearningScope.PERSONAL,
            )
        )


@pytest.mark.asyncio
async def test_record_learning_personal_scope_skips_notifications(
    svc: LearningPropagationService,
) -> None:
    stub = _StubOptimal()
    await svc.initialize(stub)
    learning = await svc.record_learning(
        RecordLearningParams(
            agent_id=uuid4(),
            agent_role="developer",
            content="some private insight",
            learning_type=LearningType.INSIGHT,
            scope=LearningScope.PERSONAL,
        )
    )
    assert isinstance(learning, Learning)
    assert learning.scope == LearningScope.PERSONAL
    assert len(stub.recorded) == 1


@pytest.mark.asyncio
async def test_record_learning_normalizes_string_enums(
    svc: LearningPropagationService,
) -> None:
    stub = _StubOptimal()
    await svc.initialize(stub)
    learning = await svc.record_learning(
        RecordLearningParams(
            agent_id=uuid4(),
            agent_role="qa",
            content="content",
            learning_type="solution",
            scope="personal",
        )
    )
    assert learning.learning_type == LearningType.SOLUTION
    assert learning.scope == LearningScope.PERSONAL


@pytest.mark.asyncio
async def test_record_learning_team_scope_calls_create_notifications(
    svc: LearningPropagationService,
) -> None:
    """Team-scope learnings call _create_notifications; best-effort logs on error."""
    stub = _StubOptimal()
    await svc.initialize(stub)
    # The notifications branch will silently fail because there's no DB
    # context inside the unit-test environment — that's fine; we just want
    # to cover the code path.
    learning = await svc.record_learning(
        RecordLearningParams(
            agent_id=uuid4(),
            agent_role="developer",
            content="team-scoped lesson",
            learning_type=LearningType.PATTERN,
            scope=LearningScope.TEAM,
        )
    )
    assert learning.scope == LearningScope.TEAM


@pytest.mark.asyncio
async def test_get_learnings_for_agent_requires_initialization(
    svc: LearningPropagationService,
) -> None:
    with pytest.raises(RuntimeError, match="not initialized"):
        await svc.get_learnings_for_agent(uuid4(), "developer")


@pytest.mark.asyncio
async def test_get_learnings_for_agent_returns_filtered_results(
    svc: LearningPropagationService,
) -> None:
    aid = uuid4()
    other_id = uuid4()

    def _r(metadata: dict, score: float = 0.7) -> SearchResult:
        return SearchResult(
            content="x",
            source="test",
            score=score,
            index_type=IndexType.LEARNINGS,
            metadata=metadata,
        )

    own_personal = _r(
        {"scope": "personal", "agent_id": str(aid), "agent_role": "developer"},
        score=0.9,
    )
    other_personal = _r(
        {
            "scope": "personal",
            "agent_id": str(other_id),
            "agent_role": "developer",
        },
        score=0.9,
    )
    team_visible = _r({"scope": "team", "agent_role": "developer"})
    team_other_role = _r({"scope": "team", "agent_role": "qa"})
    org_visible = _r({"scope": "org", "agent_role": "qa"}, score=0.5)
    stub = _StubOptimal(
        results=[
            own_personal,
            other_personal,
            team_visible,
            team_other_role,
            org_visible,
        ]
    )
    await svc.initialize(stub)
    out = await svc.get_learnings_for_agent(aid, "developer")
    # Visible: own personal, team for matching role, org-anyone.
    # Filtered out: other-personal & team-other-role.
    contents = [r.metadata for r in out]
    assert own_personal.metadata in contents
    assert team_visible.metadata in contents
    assert org_visible.metadata in contents
    assert other_personal.metadata not in contents
    assert team_other_role.metadata not in contents


@pytest.mark.asyncio
async def test_search_similar_learnings_requires_initialization(
    svc: LearningPropagationService,
) -> None:
    with pytest.raises(RuntimeError, match="not initialized"):
        await svc.search_similar_learnings("anything")


@pytest.mark.asyncio
async def test_search_similar_learnings_passes_through(
    svc: LearningPropagationService,
) -> None:
    stub = _StubOptimal(
        results=[
            SearchResult(
                content="x",
                source="test",
                score=1.0,
                index_type=IndexType.LEARNINGS,
                metadata={},
            )
        ]
    )
    await svc.initialize(stub)
    out = await svc.search_similar_learnings("how to debug", top_k=3)
    assert len(out) == 1
    _TOP_K = 3
    assert stub.searches[0]["top_k"] == _TOP_K
    assert IndexType.LEARNINGS in stub.searches[0]["index_types"]


@pytest.mark.asyncio
async def test_mark_learning_helpful_logs(
    svc: LearningPropagationService,
) -> None:
    """Just exercises the log call — no error path."""
    await svc.mark_learning_helpful("lrn-abc", uuid4(), helpful=True)
    await svc.mark_learning_helpful("lrn-abc", uuid4(), helpful=False)


@pytest.mark.asyncio
async def test_mark_learning_used_logs(svc: LearningPropagationService) -> None:
    await svc.mark_learning_used("lrn-abc", uuid4(), context="tried this")
    await svc.mark_learning_used("lrn-abc", uuid4())


@pytest.mark.asyncio
async def test_get_pending_notifications_filters_by_agent(
    svc: LearningPropagationService,
) -> None:
    aid = uuid4()
    other_id = uuid4()
    svc._notification_queue.append(
        LearningNotification(
            notification_id="n1",
            learning_id="lrn-1",
            target_agent_id=aid,
            learning_summary="s",
            reason="r",
            created_at="2026-01-01",
        )
    )
    svc._notification_queue.append(
        LearningNotification(
            notification_id="n2",
            learning_id="lrn-2",
            target_agent_id=other_id,
            learning_summary="s",
            reason="r",
            created_at="2026-01-01",
        )
    )
    pending = await svc.get_pending_notifications(aid)
    assert len(pending) == 1
    assert pending[0].notification_id == "n1"


@pytest.mark.asyncio
async def test_get_pending_excludes_already_acknowledged(
    svc: LearningPropagationService,
) -> None:
    aid = uuid4()
    svc._notification_queue.append(
        LearningNotification(
            notification_id="n1",
            learning_id="lrn-1",
            target_agent_id=aid,
            learning_summary="s",
            reason="r",
            created_at="2026-01-01",
            acknowledged=True,
        )
    )
    pending = await svc.get_pending_notifications(aid)
    assert pending == []


@pytest.mark.asyncio
async def test_acknowledge_notification(svc: LearningPropagationService) -> None:
    aid = uuid4()
    svc._notification_queue.append(
        LearningNotification(
            notification_id="n1",
            learning_id="lrn-1",
            target_agent_id=aid,
            learning_summary="s",
            reason="r",
            created_at="2026-01-01",
        )
    )
    assert await svc.acknowledge_notification("n1", aid) is True
    pending = await svc.get_pending_notifications(aid)
    assert pending == []


@pytest.mark.asyncio
async def test_acknowledge_notification_returns_false_when_missing(
    svc: LearningPropagationService,
) -> None:
    assert await svc.acknowledge_notification("ghost", uuid4()) is False


@pytest.mark.asyncio
async def test_get_learning_stats_returns_dict_with_expected_keys(
    svc: LearningPropagationService,
) -> None:
    stats = await svc.get_learning_stats()
    assert "total_learnings" in stats
    assert "by_type" in stats
    assert "by_scope" in stats


@pytest.mark.asyncio
async def test_get_learning_service_factory() -> None:
    """get_learning_service returns a singleton instance."""
    _LearningServiceHolder.instance = None
    a = await get_learning_service()
    b = await get_learning_service()
    assert a is b
    _LearningServiceHolder.instance = None
