"""Unit tests for the §6.2 termination-path → AgentOutcome mapper.

These exercise the pure logic of :func:`_decide_outcome`, which does
not require a live pi session.
"""

from __future__ import annotations

from attractor.agent import (
    MAX_TURNS_MESSAGE_TEMPLATE,
    NO_PROGRESS_LABEL,
    PROVIDER_ERROR_PREFIX,
    AgentOutcome,
    OutcomeStatus,
    ReportOutcomeState,
)
from attractor.agent.runner import (
    _decide_outcome,  # pyright: ignore[reportPrivateUsage]
)


def _state(invoked: bool = False, success: bool = False, reason: str = "") -> ReportOutcomeState:
    return ReportOutcomeState(invoked=invoked, success=success, reason=reason)


# [unit->REQ-AGENT-SESSION-API]
def test_path_a_natural_turn_end_returns_success_with_text() -> None:
    """Path (a-ii): agent did work AND ended with final text → SUCCESS.

    The no-progress sub-case (a-i) is exercised separately below; this
    test pins the v0.1 happy path — at least one tool call, final
    assistant text, no errors, no `report_outcome`.
    """
    outcome = _decide_outcome(
        outcome_state=_state(),
        last_final_text="all green",
        terminal_error_message=None,
        usage_totals=[100, 50],
        tool_calls_seen=3,
    )
    assert outcome == AgentOutcome(
        status=OutcomeStatus.SUCCESS,
        captured_output="all green",
        usage_input_tokens=100,
        usage_output_tokens=50,
    )


# [unit->REQ-AGENT-TOOLS-V01]
def test_path_b_report_outcome_failure_overrides_assistant_text() -> None:
    """Path (b): explicit tool call beats any trailing text."""
    outcome = _decide_outcome(
        outcome_state=_state(invoked=True, success=False, reason="3 failures"),
        last_final_text="ignore me — model rambled before reporting",
        terminal_error_message=None,
        usage_totals=[0, 0],
    )
    assert outcome.status == OutcomeStatus.FAILURE
    assert outcome.captured_output == "3 failures"


# [unit->REQ-EXEC-OUTCOME-NO-OP]
def test_path_b_report_outcome_no_op_carries_reason() -> None:
    outcome = _decide_outcome(
        outcome_state=ReportOutcomeState(
            invoked=True,
            success=False,
            reason="already fixed on main",
            status=OutcomeStatus.NO_OP,
        ),
        last_final_text="trailing text",
        terminal_error_message=None,
        usage_totals=[0, 0],
    )
    assert outcome.status == OutcomeStatus.NO_OP
    assert outcome.captured_output == "already fixed on main"


# [unit->REQ-AGENT-TOOLS-V01]
def test_path_b_report_outcome_success_carries_reason() -> None:
    outcome = _decide_outcome(
        outcome_state=_state(invoked=True, success=True, reason="ok"),
        last_final_text="trailing text",
        terminal_error_message=None,
        usage_totals=[0, 0],
    )
    assert outcome.status == OutcomeStatus.SUCCESS
    assert outcome.captured_output == "ok"


# [unit->REQ-AGENT-SESSION-API]
def test_path_c_max_turns_message_is_stable() -> None:
    """Path (c): terminal_error wins over last_final_text."""
    msg = MAX_TURNS_MESSAGE_TEMPLATE.format(n=50)
    outcome = _decide_outcome(
        outcome_state=_state(),
        last_final_text="partial work",
        terminal_error_message=msg,
        usage_totals=[7, 3],
    )
    assert outcome.status == OutcomeStatus.FAILURE
    assert outcome.captured_output == "max_turns (50) exceeded"
    assert outcome.usage_input_tokens == 7


# [unit->REQ-AGENT-SESSION-API]
def test_path_d_provider_error_message_is_prefixed() -> None:
    """Path (d): provider error surfaced with stable prefix."""
    outcome = _decide_outcome(
        outcome_state=_state(),
        last_final_text="",
        terminal_error_message=f"{PROVIDER_ERROR_PREFIX}rate limit hit",
        usage_totals=[0, 0],
    )
    assert outcome.status == OutcomeStatus.FAILURE
    assert outcome.captured_output.startswith(PROVIDER_ERROR_PREFIX)


# [unit->REQ-AGENT-SESSION-API]
def test_tool_invocation_beats_terminal_error() -> None:
    """If the model called report_outcome AND we then saw an error,
    explicit declaration still wins — the agent told us what it meant."""
    outcome = _decide_outcome(
        outcome_state=_state(invoked=True, success=True, reason="declared ok"),
        last_final_text="",
        terminal_error_message=f"{PROVIDER_ERROR_PREFIX}network",
        usage_totals=[0, 0],
    )
    assert outcome.status == OutcomeStatus.SUCCESS
    assert outcome.captured_output == "declared ok"


# ─── §6.2(a) no-progress classification (REQ-EXEC-OUTCOME-NO-PROGRESS) ──


# [unit->REQ-EXEC-OUTCOME-NO-PROGRESS]
def test_no_progress_classified_when_zero_tool_calls_with_text() -> None:
    """Agent ends cleanly with explanatory text but did no work →
    PARTIAL_SUCCESS with preferred_label=NO_PROGRESS_LABEL.

    Closes the §6.2(a) silent-success bug (issue #5) for the canonical
    "agent gave up without doing anything" case.
    """
    outcome = _decide_outcome(
        outcome_state=_state(),
        last_final_text="I can't write that file — permission denied.",
        terminal_error_message=None,
        usage_totals=[12, 34],
        tool_calls_seen=0,
    )
    assert outcome.status == OutcomeStatus.PARTIAL_SUCCESS
    assert outcome.preferred_label == NO_PROGRESS_LABEL
    assert (
        outcome.captured_output
        == "I can't write that file — permission denied."
    )
    assert outcome.usage_input_tokens == 12


# [unit->REQ-EXEC-OUTCOME-NO-PROGRESS]
def test_with_tool_calls_stays_success() -> None:
    """If the agent did any work (≥1 tool call), the no-progress
    heuristic doesn't trip — path (a) returns SUCCESS as before.
    """
    outcome = _decide_outcome(
        outcome_state=_state(),
        last_final_text="Done — wrote 3 files.",
        terminal_error_message=None,
        usage_totals=[0, 0],
        tool_calls_seen=5,
    )
    assert outcome.status == OutcomeStatus.SUCCESS
    assert outcome.preferred_label is None


# [unit->REQ-EXEC-OUTCOME-NO-PROGRESS]
def test_no_progress_yields_to_report_outcome() -> None:
    """An explicit `report_outcome` declaration beats the heuristic.

    The agent told us what it meant — don't second-guess via the
    zero-tool-calls signature.
    """
    outcome = _decide_outcome(
        outcome_state=_state(invoked=True, success=True, reason="declared ok"),
        last_final_text="some final message",
        terminal_error_message=None,
        usage_totals=[0, 0],
        tool_calls_seen=0,
    )
    assert outcome.status == OutcomeStatus.SUCCESS
    assert outcome.preferred_label is None
    assert outcome.captured_output == "declared ok"


# [unit->REQ-EXEC-OUTCOME-NO-PROGRESS]
def test_no_progress_yields_to_terminal_error() -> None:
    """A terminal error (max_turns / provider failure) takes priority
    over the heuristic — the run hit a hard limit, not a soft give-up.
    """
    msg = MAX_TURNS_MESSAGE_TEMPLATE.format(n=50)
    outcome = _decide_outcome(
        outcome_state=_state(),
        last_final_text="explanatory text the agent emitted before the cap",
        terminal_error_message=msg,
        usage_totals=[0, 0],
        tool_calls_seen=0,
    )
    assert outcome.status == OutcomeStatus.FAILURE
    assert outcome.preferred_label is None
    assert outcome.captured_output == msg


# [unit->REQ-EXEC-OUTCOME-NO-PROGRESS]
def test_empty_text_zero_tool_calls_still_returns_success() -> None:
    """No tool calls AND no final text → SUCCESS (the v0.1 silent path).

    There's no explanatory text to act on, so flagging as no-progress
    would just add noise. The heuristic requires both signals.
    """
    outcome = _decide_outcome(
        outcome_state=_state(),
        last_final_text="",
        terminal_error_message=None,
        usage_totals=[0, 0],
        tool_calls_seen=0,
    )
    assert outcome.status == OutcomeStatus.SUCCESS
    assert outcome.preferred_label is None


# [unit->REQ-EXEC-OUTCOME-NO-PROGRESS]
def test_no_progress_label_is_stable_constant() -> None:
    """The sentinel string is part of the routing contract — workflow
    authors wire `label="no-progress"` against this exact value.
    """
    assert NO_PROGRESS_LABEL == "no-progress"


