"""Unit tests for JournalService gateway-backfill methods."""

from __future__ import annotations

from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID, uuid4

import pytest
from roboco.models.base import JournalEntryType
from roboco.models.optimal import IndexJournalEntryParams
from roboco.services.journal import JournalService, drain_rag_index_tasks


def _service_with_count(count: int) -> JournalService:
    """Build a JournalService whose count query returns `count`."""
    result = MagicMock()
    result.scalar.return_value = count
    session = MagicMock()
    session.execute = AsyncMock(return_value=result)
    session.flush = AsyncMock()
    return JournalService(session)


def _service_with_scalar(value: object) -> JournalService:
    """Build a JournalService whose scalar query returns `value`.

    Mirrors `_service_with_count` but lets the test inject any value
    (including a `datetime` or `None`) for the single-column query path
    used by `latest_decision_at`.
    """
    result = MagicMock()
    result.scalar.return_value = value
    session = MagicMock()
    session.execute = AsyncMock(return_value=result)
    session.flush = AsyncMock()
    return JournalService(session)


@pytest.mark.asyncio
async def test_has_decision_for_task_true_when_count_positive() -> None:
    svc = _service_with_count(1)
    assert await svc.has_decision_for_task(uuid4(), uuid4()) is True


@pytest.mark.asyncio
async def test_has_decision_for_task_false_when_zero() -> None:
    svc = _service_with_count(0)
    assert await svc.has_decision_for_task(uuid4(), uuid4()) is False


@pytest.mark.asyncio
async def test_has_learning_for_task_true_when_count_positive() -> None:
    svc = _service_with_count(2)
    assert await svc.has_learning_for_task(uuid4(), uuid4()) is True


@pytest.mark.asyncio
async def test_has_learning_for_task_false_when_zero() -> None:
    svc = _service_with_count(0)
    assert await svc.has_learning_for_task(uuid4(), uuid4()) is False


@pytest.mark.asyncio
async def test_has_reflect_for_task_true_when_count_positive() -> None:
    svc = _service_with_count(3)
    assert await svc.has_reflect_for_task(uuid4(), uuid4()) is True


@pytest.mark.asyncio
async def test_has_reflect_for_task_false_when_zero() -> None:
    svc = _service_with_count(0)
    assert await svc.has_reflect_for_task(uuid4(), uuid4()) is False


def _bind(svc: JournalService, name: str, value: object) -> None:
    object.__setattr__(svc, name, value)


@pytest.mark.asyncio
async def test_write_struggle_calls_add_struggle_with_task_id() -> None:
    """write_struggle delegates to add_struggle with a STRUGGLE-typed entry.

    The struggle is built via StruggleEntryParams; we verify the service
    passes through the agent_id + task_id and uses the first content line
    as title (truncated to 100 chars).
    """
    svc = JournalService(MagicMock(flush=AsyncMock()))
    add_struggle_mock = AsyncMock(
        return_value=MagicMock(type=JournalEntryType.STRUGGLE)
    )
    _bind(svc, "add_struggle", add_struggle_mock)
    agent_id = uuid4()
    task_id = uuid4()
    content = "Cannot find the right migration file\nMore detail here"
    out = await svc.write_struggle(agent_id=agent_id, task_id=task_id, content=content)
    assert out is not None
    add_struggle_mock.assert_awaited_once()
    args, _kwargs = add_struggle_mock.call_args
    # First arg is agent_id; second is StruggleEntryParams
    assert args[0] == agent_id
    params = args[1]
    assert params.task_id == task_id
    assert params.title == "Cannot find the right migration file"
    assert content in params.what_struggled


@pytest.mark.asyncio
async def test_write_struggle_handles_empty_content_gracefully() -> None:
    svc = JournalService(MagicMock(flush=AsyncMock()))
    add_struggle_mock = AsyncMock(return_value=MagicMock())
    _bind(svc, "add_struggle", add_struggle_mock)
    await svc.write_struggle(agent_id=uuid4(), task_id=uuid4(), content="")
    args, _kwargs = add_struggle_mock.call_args
    params = args[1]
    assert params.title == "Struggle"


@pytest.mark.asyncio
async def test_write_decision_calls_add_decision_log_with_task_id() -> None:
    """write_decision delegates to add_decision_log with a DECISION_LOG entry.

    Title comes from the first content line (truncated to 100 chars); the
    full content is carried in the rationale field so the auto-recorded
    decision reads coherently in journal lists and RAG retrieval.
    """
    svc = JournalService(MagicMock(flush=AsyncMock()))
    add_decision_mock = AsyncMock(
        return_value=MagicMock(type=JournalEntryType.DECISION_LOG)
    )
    _bind(svc, "add_decision_log", add_decision_mock)
    agent_id = uuid4()
    task_id = uuid4()
    content = "Completing PR #120: all 3 acceptance criteria verified\nmore detail"
    out = await svc.write_decision(agent_id=agent_id, task_id=task_id, content=content)
    assert out is not None
    add_decision_mock.assert_awaited_once()
    args, _kwargs = add_decision_mock.call_args
    assert args[0] == agent_id
    params = args[1]
    assert params.task_id == task_id
    assert params.title == "Completing PR #120: all 3 acceptance criteria verified"
    assert content in params.rationale


@pytest.mark.asyncio
async def test_write_decision_handles_empty_content_gracefully() -> None:
    svc = JournalService(MagicMock(flush=AsyncMock()))
    add_decision_mock = AsyncMock(return_value=MagicMock())
    _bind(svc, "add_decision_log", add_decision_mock)
    await svc.write_decision(agent_id=uuid4(), task_id=uuid4(), content="")
    args, _kwargs = add_decision_mock.call_args
    params = args[1]
    assert params.title == "Decision"


# ---------------------------------------------------------------------------
# latest_decision_at — windowed-satisfaction support for the PM-decision gate
# (C8). Returns the `created_at` of the newest DECISION_LOG entry for an
# (agent, task) pair, or None if no decision exists.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_latest_decision_at_returns_none_when_no_decision() -> None:
    """No DECISION_LOG entries → scalar query returns None → method returns None."""
    svc = _service_with_scalar(None)
    assert await svc.latest_decision_at(uuid4(), uuid4()) is None


@pytest.mark.asyncio
async def test_latest_decision_at_returns_timestamp_of_single_decision() -> None:
    """One DECISION_LOG entry → returns its `created_at`."""
    expected = datetime(2026, 5, 12, 10, 0, 0, tzinfo=UTC)
    svc = _service_with_scalar(expected)
    out = await svc.latest_decision_at(uuid4(), uuid4())
    assert out == expected


@pytest.mark.asyncio
async def test_latest_decision_at_returns_most_recent_when_multiple_decisions() -> None:
    """SQL `max(created_at)` returns the newest; method passes it through.

    The DB does the max() reduction in the query — the mock returns
    whatever the scalar would; we assert the method respects that value.
    """
    newest = datetime(2026, 5, 12, 12, 30, 0, tzinfo=UTC)
    svc = _service_with_scalar(newest)
    out = await svc.latest_decision_at(uuid4(), uuid4())
    assert out == newest


@pytest.mark.asyncio
async def test_latest_decision_at_filters_by_agent_id() -> None:
    """The query filters by (agent_id, task_id) — a decision by another
    agent on the same task must NOT count. The mock returns None to
    represent the post-filter empty set."""
    svc = _service_with_scalar(None)
    out = await svc.latest_decision_at(uuid4(), uuid4())
    assert out is None


# ---------------------------------------------------------------------------
# delete_entry — C3: must de-index the entry from the RAG after the row
# commit so deleted/private journal content stops bleeding into claim-time
# briefings. Best-effort: a de-index failure never errors the delete.
# ---------------------------------------------------------------------------


def _entry(journal_id: UUID, entry_id: UUID) -> Any:
    entry = MagicMock()
    entry.id = entry_id
    entry.journal_id = journal_id
    entry.type = JournalEntryType.GENERAL
    return entry


def _journal(journal_id: UUID) -> Any:
    journal = MagicMock()
    journal.id = journal_id
    journal.total_entries = 1
    journal.entries_by_type = {JournalEntryType.GENERAL.value: 1}
    return journal


def _session_for_delete(entry: Any, journal: Any) -> Any:
    """Session whose two queries return the entry then its journal, and
    whose delete/commit are AsyncMocks."""
    session = MagicMock()
    result_entry = MagicMock()
    result_entry.scalar_one_or_none.return_value = entry
    result_journal = MagicMock()
    result_journal.scalar_one_or_none.return_value = journal
    session.execute = AsyncMock(side_effect=[result_entry, result_journal])
    session.delete = AsyncMock()
    session.commit = AsyncMock()
    return session


@pytest.mark.asyncio
async def test_delete_entry_deindexes_after_commit() -> None:
    """C3: delete_entry commits the row delete, then calls
    unindex_journal_entry with the entry id so the RAG chunks + tracking
    row are removed. The OptimalService singleton is patched so no real
    pgvector round-trip happens."""
    journal_id = uuid4()
    entry_id = uuid4()
    entry = _entry(journal_id, entry_id)
    journal = _journal(journal_id)
    session = _session_for_delete(entry, journal)
    svc = JournalService(session)

    optimal = MagicMock()
    optimal.unindex_journal_entry = AsyncMock(return_value=None)
    with (
        patch(
            "roboco.services.optimal.get_optimal_service",
            AsyncMock(return_value=optimal),
        ),
    ):
        out = await svc.delete_entry(entry_id)

    assert out is True
    session.commit.assert_awaited_once()
    optimal.unindex_journal_entry.assert_awaited_once_with(entry_id)


# ---------------------------------------------------------------------------
# _schedule_rag_index — dead-letter on embedder failure
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_schedule_rag_index_dead_letters_on_failure() -> None:
    """An embedder failure (e.g. Ollama 429) is persisted to the rag_index_failures
    dead-letter instead of dropped. The fire-and-forget posture is preserved:
    _schedule_rag_index returns without awaiting the index, and the caller's
    commit is never blocked."""
    session = MagicMock()
    session.commit = AsyncMock()
    svc = JournalService(session)

    optimal = MagicMock()
    optimal.index_journal_entry = AsyncMock(side_effect=RuntimeError("ollama 429"))
    optimal.record_learning = AsyncMock(return_value=None)

    params = IndexJournalEntryParams(
        content="lesson learned",
        entry_type=JournalEntryType.GENERAL.value,
        entry_id=uuid4(),
        agent_id=uuid4(),
        task_id=uuid4(),
        tags=["t"],
    )

    with (
        patch(
            "roboco.services.optimal.get_optimal_service",
            AsyncMock(return_value=optimal),
        ),
        patch(
            "roboco.services.rag_index_failures.persist_failure",
            new=AsyncMock(),
        ) as mock_persist,
    ):
        # Fire-and-forget: returns synchronously, does not await the index.
        svc._schedule_rag_index(params, is_private=False)
        await drain_rag_index_tasks()

    # The failure was dead-lettered, not just logged.
    mock_persist.assert_awaited_once()
    call_args = mock_persist.await_args
    assert call_args is not None
    assert call_args.args[0] == "journal_entry"
    payload = call_args.args[1]
    assert payload["entry_id"] == str(params.entry_id)
    assert payload["is_private"] is False
    # The caller's commit was never touched by the index path.
    session.commit.assert_not_awaited()


@pytest.mark.asyncio
async def test_schedule_rag_index_no_dead_letter_on_success() -> None:
    """A successful index write does not persist a dead-letter row."""
    session = MagicMock()
    svc = JournalService(session)

    optimal = MagicMock()
    optimal.index_journal_entry = AsyncMock(return_value=None)
    optimal.record_learning = AsyncMock(return_value=None)

    params = IndexJournalEntryParams(
        content="ok",
        entry_type=JournalEntryType.GENERAL.value,
        entry_id=uuid4(),
    )

    with (
        patch(
            "roboco.services.optimal.get_optimal_service",
            AsyncMock(return_value=optimal),
        ),
        patch(
            "roboco.services.rag_index_failures.persist_failure",
            new=AsyncMock(),
        ) as mock_persist,
    ):
        svc._schedule_rag_index(params, is_private=False)
        await drain_rag_index_tasks()

    mock_persist.assert_not_awaited()


@pytest.mark.asyncio
async def test_delete_entry_swallows_deindex_failure() -> None:
    """A de-index failure must not error the delete — the row is already
    deleted, so the caller's contract (returns True) holds."""
    journal_id = uuid4()
    entry_id = uuid4()
    entry = _entry(journal_id, entry_id)
    journal = _journal(journal_id)
    session = _session_for_delete(entry, journal)
    svc = JournalService(session)

    optimal = MagicMock()
    optimal.unindex_journal_entry = AsyncMock(side_effect=RuntimeError("rag blew up"))
    with (
        patch(
            "roboco.services.optimal.get_optimal_service",
            AsyncMock(return_value=optimal),
        ),
    ):
        out = await svc.delete_entry(entry_id)

    assert out is True
    session.commit.assert_awaited_once()
