"""Deterministic loop outcomes (context overflow, empty reply, persistence failure) end as
failed turns whose chat copy names the slash command to run, and whose ``failure_reason``
lets the desktop card pick a code-specific action instead of a blind Retry.
"""
from __future__ import annotations

from types import SimpleNamespace

import pytest

from agent.error_surface import build_error_surface_from_result
from agent.turn_explainers import EMPTY_RESPONSE_EXPLANATION, TurnExplainersMixin
from agent.turn_failure_copy import (
    SITE_FAILURE_CODES, exit_reason_failure, failure_cause_gloss,
)
from agent.turn_overflow import _Recovery


def _recovery():
    agent = SimpleNamespace(
        model="gpt-5", log_prefix="", _flush_status_buffer=lambda: None, _vprint=lambda *a, **k: None,
        _persist_session=lambda *a, **k: None,
    )
    return _Recovery(
        agent=agent, api_messages=[], system_message=None, effective_task_id="t", api_call_count=2,
        max_compression_attempts=3, messages=[], active_system_prompt=None, conversation_history=None,
        approx_tokens=200_000, compression_attempts=3,
    )


def test_overflow_exhaustion_is_non_retryable_context_overflow_with_slash_commands():
    verdict = _recovery().count_attempt()
    result = verdict.result
    assert result["failed"] is True and result["compression_exhausted"] is True
    assert result["failure_reason"] == "context_overflow" and result["failure_retryable"] is False
    text = result["final_response"]
    assert "/new" in text and "/compress" in text
    assert build_error_surface_from_result(result)["code"] == "context_overflow"


def _context_rejection(request_tokens: int, window: int = 65_536, error="HTTP 500: Context size has been exceeded.",
                       base_url: str = "http://127.0.0.1:1234/v1"):
    """Drive ``_recover_context_length`` with a provider "context exceeded" and a request the rough
    estimator prices at ``request_tokens`` against a ``window``-token model (no output cap)."""
    from unittest.mock import patch

    from agent.turn_overflow import _recover_context_length
    from agent.turn_retry_state import TurnRetryState

    st = _recovery()
    st.compression_attempts = 0
    st.agent.max_tokens = None
    st.agent.context_compressor = SimpleNamespace(context_length=window)
    st.agent.provider, st.agent.base_url, st.agent.tools = "lmstudio", base_url, None
    st.agent._buffer_vprint = st.agent._buffer_diagnostic_status = lambda *a, **k: None
    compressed = []
    st.agent._compress_context = lambda msgs, *a, **k: (compressed.append(1) or [{"role": "user", "content": "x"}], None)
    with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=request_tokens), \
         patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=[request_tokens, 10]), \
         patch("agent.turn_overflow.time.sleep", lambda _: None):
        verdict = _recover_context_length(st, TurnRetryState(), error)
    return verdict, compressed


def test_context_rejection_far_below_the_window_is_not_blamed_on_the_conversation():
    """A single-slot local server rejecting a ~3k-token request (another thread held its
    context) must not read as "this conversation has grown too long": no compression, transient
    + retryable, and the gateway's overflow verdict (drop message / auto-reset) stays off."""
    from gateway.run_turn import is_context_overflow_failure_result

    verdict, compressed = _context_rejection(3_000)
    result = verdict.result
    assert verdict.action == "return" and not compressed
    assert result["failed"] is True and not result.get("compression_exhausted")
    assert result["failure_reason"] == "server_error" and result["failure_retryable"] is True
    text = result["final_response"]
    assert "grown too long" not in text and "/new" not in text
    assert "3,000" in text and "65,536" in text and "background" in text and "/retry" in text
    assert is_context_overflow_failure_result(result, history_len=2) is False


def test_context_rejection_near_the_window_still_compresses():
    """Control: a request that plausibly overflows keeps the compress-and-retry path, and so does
    a small local estimate when the SERVER quoted its own count — its measurement wins."""
    verdict, compressed = _context_rejection(60_000)
    assert compressed and verdict.action == "break"
    verdict, compressed = _context_rejection(3_000, error="prompt is too long: 70000 tokens > 65536 maximum")
    assert compressed and verdict.action == "break"


def test_hosted_context_rejection_far_below_the_known_window_compresses():
    """A hosted route has no shared slot to wait out: a small request rejected there means the
    route's real window is below the one Hermes assumes, so /retry would fail forever. Compress."""
    verdict, compressed = _context_rejection(
        47_000, window=1_000_000, base_url="https://api.anthropic.com",
        error="This model's maximum context length was exceeded. Please reduce the length of the messages.",
    )
    assert compressed and verdict.action == "break"


def test_empty_response_exhaustion_has_one_text_everywhere():
    """One constant feeds the CLI explainer and the gateway '(empty)' rewrite; no surface
    asserts 'after processing tool results' or 'inspect the tool output above'."""
    verdict = exit_reason_failure("empty_response_exhausted")
    assert (verdict.reason, verdict.retryable) == ("empty_response", True)
    text = TurnExplainersMixin._format_turn_completion_explanation("empty_response_exhausted", model="llama3")
    assert EMPTY_RESPONSE_EXPLANATION.format(model="llama3") in text


def test_persistence_failure_default_copy_is_actionable_and_profile_aware(monkeypatch):
    monkeypatch.setenv("HERMES_HOME", "/srv/hermes-profile")
    text = TurnExplainersMixin._format_turn_completion_explanation("session_persistence_failed", "replaced")
    assert "hermes gateway stop" in text and "hermes doctor" in text
    assert "~/.hermes" not in text and "/srv/hermes-profile" in text
    assert "manifest" not in text  # the runbook stays in logger.error at hermes_state




@pytest.mark.parametrize("exit_reason", ["empty_response_exhausted", "local_processing_error(TypeError)"])
def test_advisory_exit_reasons_stamp_a_code_but_never_flip_failed(exit_reason):
    """Cron silence, the kanban dispatcher breaker and gateway transcript persistence all key
    on ``failed``; these two exits must keep failed=False and only gain the descriptor code."""
    verdict = exit_reason_failure(exit_reason)
    assert verdict is not None and verdict.fails_turn is False
    assert verdict.reason in SITE_FAILURE_CODES


@pytest.mark.parametrize("exit_reason, code", [
    ("context_compression_timeout", "context_overflow"),
    ("ollama_runtime_context_too_small", "context_overflow"),
    ("redirect_restart_limit_exceeded", "loop_error"),
    ("rebuilt_restart_limit_exceeded", "loop_error"),
])
def test_previously_bare_exit_reasons_now_carry_a_code(exit_reason, code):
    verdict = exit_reason_failure(exit_reason)
    assert verdict is not None and verdict.reason == code
    assert build_error_surface_from_result(
        {"failed": True, "error": "x", "failure_reason": verdict.reason, "failure_retryable": verdict.retryable}
    )["code"] == code


def test_every_failure_code_copy_key_is_a_failure_code():
    """The copy table split: keys of the failure-code table are exactly codes the descriptor
    contract lists (one-off strings live in their own table)."""
    from agent.turn_failure_copy import _FAILURE_CODE_COPY, _ONE_OFF_COPY

    assert set(_FAILURE_CODE_COPY) <= SITE_FAILURE_CODES
    assert not (set(_ONE_OFF_COPY) & SITE_FAILURE_CODES)


def test_cause_gloss_substitutes_the_subject_and_skips_unknown_reasons():
    assert "the job's" in failure_cause_gloss("context_overflow", subject="this job", possessive="the job's")
    assert failure_cause_gloss("model_not_found")
    assert failure_cause_gloss("unknown") is None and failure_cause_gloss(None) is None
