"""#84733: prompt-cache TTL/prefix propagation into MoA/aux paths + failover re-preflight.

The main loop threads ``agent._cache_ttl`` and the stable system prefix into
``build_prompt_cache_plan``, but the MoA/aux helper only accepted
``cache_disabled`` — so a configured ``1h`` regressed to the 5m default and
the destination system prompt was marked as one whole breakpoint. These
tests pin the threaded parameters (TTL + static prefix) on
``plan_cache_sections_for_destination`` and the MoA decoration helper, the
per-destination Qwen clamp (1h -> 5m), and the failover re-preflight
contract (every fallback activation must restart the outer iteration so the
pre-API preflight re-runs against the fallback's context window).
"""

import ast
import inspect


def _collect_cache_controls(obj):
    """Return every ``cache_control`` marker dict reachable in ``obj``."""
    markers = []
    if isinstance(obj, dict):
        if "cache_control" in obj:
            markers.append(obj["cache_control"])
        for value in obj.values():
            markers.extend(_collect_cache_controls(value))
    elif isinstance(obj, list):
        for value in obj:
            markers.extend(_collect_cache_controls(value))
    return markers


class TestPlanCacheSectionsThreadsTtlAndPrefix:
    def test_cache_ttl_1h_reaches_markers(self):
        from agent.agent_runtime_helpers import plan_cache_sections_for_destination

        messages = [
            {"role": "system", "content": "You are helpful."},
            {"role": "user", "content": "hello"},
        ]
        out_msgs, _ = plan_cache_sections_for_destination(
            messages,
            None,
            provider="anthropic",
            base_url="https://api.anthropic.com",
            api_mode="anthropic_messages",
            model="claude-opus-4.8",
            cache_disabled=False,
            cache_ttl="1h",
        )
        markers = _collect_cache_controls(out_msgs)
        assert markers, "expected cache_control markers on a caching route"
        assert all(m.get("ttl") == "1h" for m in markers), (
            "the configured 1h tier must reach the destination plan markers"
        )

    def test_static_system_prefix_gets_early_breakpoint(self):
        from agent.agent_runtime_helpers import plan_cache_sections_for_destination

        messages = [
            {"role": "system", "content": "stable prefix\nvolatile suffix"},
            {"role": "user", "content": "hello"},
        ]
        out_msgs, _ = plan_cache_sections_for_destination(
            messages,
            None,
            provider="anthropic",
            base_url="https://api.anthropic.com",
            api_mode="anthropic_messages",
            model="claude-opus-4.8",
            cache_disabled=False,
            cache_ttl="5m",
            static_system_prefix="stable prefix",
        )
        system_content = out_msgs[0]["content"]
        assert isinstance(system_content, list) and len(system_content) == 2, (
            "the destination system prompt must split into [static, volatile] "
            "parts instead of marking the whole prompt as one breakpoint"
        )
        assert system_content[0]["text"] == "stable prefix"
        assert system_content[1]["text"] == "\nvolatile suffix"

    def test_qwen_1h_clamped_to_5m(self):
        from agent.agent_runtime_helpers import plan_cache_sections_for_destination

        messages = [
            {"role": "system", "content": "You are helpful."},
            {"role": "user", "content": "hello"},
        ]
        out_msgs, _ = plan_cache_sections_for_destination(
            messages,
            None,
            provider="opencode",
            base_url="https://api.opencode.ai",
            api_mode="chat_completions",
            model="qwen3.6-plus",
            cache_disabled=False,
            cache_ttl="1h",
        )
        markers = _collect_cache_controls(out_msgs)
        assert markers, "opencode+qwen is a cache-honoring route"
        assert all("ttl" not in m for m in markers), (
            "Qwen's 5-minute-only context cache must clamp a configured 1h"
        )


class TestMoACacheControlThreadsTtl:
    def test_moa_decoration_uses_threaded_1h(self):
        from agent.moa_loop import _maybe_apply_moa_cache_control

        messages = [
            {"role": "system", "content": "sys"},
            {"role": "user", "content": "q1"},
            {"role": "assistant", "content": "a1"},
            {"role": "user", "content": "q2"},
        ]
        runtime = {
            "provider": "anthropic",
            "model": "claude-opus-4.8",
            "base_url": "",
            "api_mode": "anthropic_messages",
        }
        out = _maybe_apply_moa_cache_control(
            messages, runtime, cache_disabled=False, cache_ttl="1h"
        )
        markers = _collect_cache_controls(out)
        assert markers, "expected MoA decoration on a caching route"
        assert all(m.get("ttl") == "1h" for m in markers), (
            "the agent's 1h tier must stop regressing to 5m on MoA advisor calls"
        )
        # Caller messages must stay undecorated.
        assert not _collect_cache_controls(messages)

    def test_moa_qwen_1h_clamped_to_5m(self):
        from agent.moa_loop import _maybe_apply_moa_cache_control

        messages = [
            {"role": "system", "content": "sys"},
            {"role": "user", "content": "q1"},
        ]
        runtime = {
            "provider": "opencode",
            "model": "qwen3.6-plus",
            "base_url": "",
            "api_mode": "chat_completions",
        }
        out = _maybe_apply_moa_cache_control(
            messages, runtime, cache_disabled=False, cache_ttl="1h"
        )
        markers = _collect_cache_controls(out)
        assert markers, "opencode+qwen is a cache-honoring MoA route"
        assert all("ttl" not in m for m in markers), (
            "MoA decoration must clamp 1h to 5m on Qwen destinations"
        )

    def test_moa_decoration_defaults_to_5m_without_ttl(self):
        from agent.moa_loop import _maybe_apply_moa_cache_control

        messages = [
            {"role": "system", "content": "sys"},
            {"role": "user", "content": "q1"},
        ]
        runtime = {
            "provider": "anthropic",
            "model": "claude-opus-4.8",
            "base_url": "",
            "api_mode": "anthropic_messages",
        }
        out = _maybe_apply_moa_cache_control(
            messages, runtime, cache_disabled=False
        )
        markers = _collect_cache_controls(out)
        assert markers
        assert all("ttl" not in m for m in markers)


class TestFailoverRestartsPreflight:
    """#84733: a fallback provider switch must re-run the pre-API preflight.

    ``_try_activate_fallback`` already shrinks the compressor's context
    window to the fallback's; the pre-API preflight runs at the top of the
    OUTER iteration loop, before the retry loop. So the restart discipline
    is loop-aware:

    - Sites INSIDE the retry loop (``while retry_count < max_retries``)
      must ``break`` out of it with ``restart_with_rebuilt_messages`` set,
      so the handler after the retry loop refunds the budget and
      ``continue``s the outer iteration (which re-runs the preflight).
      A plain ``continue`` there would only re-fire the retry loop and
      skip the preflight — the original bug.
    - Sites DIRECTLY in the outer loop must ``continue`` — the next outer
      iteration re-runs the preflight already. A ``break`` there would
      exit the conversation loop and end the turn without ever calling
      the just-activated fallback.

    Source-level guard: parsing the function is cheap, and the assertion
    encodes the bug class — a new failover site added with the wrong
    restart statement for its loop fails here on purpose.
    """

    def test_every_fallback_activation_restarts_preflight(self):
        """The retry-loop body now lives in verdict helpers (``turn_api_call``,
        ``turn_api_error``, ``turn_response_check``): a fallback activation there must
        end in ``return _verdict("break")`` — the loop's ``break`` — so the
        ``restart_with_rebuilt_messages`` handler after the retry loop refunds the
        budget and re-runs the preflight. Every ``_try_activate_fallback`` reference
        must be a direct ``if agent._try_activate_fallback():`` site so this guard can
        bind its restart discipline (#84733)."""
        from agent import turn_api_call, turn_api_error, turn_response_check

        def _verdict_kind(stmt):
            if (
                isinstance(stmt, ast.Return)
                and isinstance(stmt.value, ast.Call)
                and isinstance(stmt.value.func, ast.Name)
                and stmt.value.func.id == "_verdict"
                and stmt.value.args
                and isinstance(stmt.value.args[0], ast.Constant)
            ):
                return stmt.value.args[0].value
            return None

        seen = 0
        for mod in (turn_api_call, turn_api_error, turn_response_check):
            tree = ast.parse(inspect.getsource(mod))
            fallback_ifs = [
                node
                for node in ast.walk(tree)
                if isinstance(node, ast.If)
                and isinstance(node.test, ast.Call)
                and isinstance(node.test.func, ast.Attribute)
                and node.test.func.attr == "_try_activate_fallback"
            ]
            all_refs = [
                node
                for node in ast.walk(tree)
                if isinstance(node, ast.Attribute)
                and node.attr == "_try_activate_fallback"
            ]
            assert len(all_refs) == len(fallback_ifs), (
                f"{mod.__name__}: every _try_activate_fallback reference must be a "
                "direct `if agent._try_activate_fallback(...):` site (#84733)"
            )
            for node in fallback_ifs:
                kinds = [_verdict_kind(stmt) for stmt in node.body]
                assert "break" in kinds, (
                    f"{mod.__name__}: retry-loop fallback activation must return "
                    "_verdict(\"break\") so the restart-with-rebuilt-messages handler "
                    "re-runs the pre-API preflight against the fallback's context "
                    "window (#84733)"
                )
                assert "continue" not in kinds, (
                    f"{mod.__name__}: a `continue` verdict would only re-fire the retry "
                    "loop and skip the preflight (#84733)"
                )
            seen += len(fallback_ifs)
        assert seen >= 5, "expected the retry-loop _try_activate_fallback sites"

        # The restart consumer must re-issue the OUTER iteration (`continue` verdict).
        from agent import turn_iteration_prep

        tree = ast.parse(inspect.getsource(turn_iteration_prep.apply_retry_restarts))
        handlers = [
            node
            for node in ast.walk(tree)
            if isinstance(node, ast.If)
            and isinstance(node.test, ast.Attribute)
            and node.test.attr == "restart_with_rebuilt_messages"
        ]
        assert handlers and all(
            any(_verdict_kind(stmt) == "continue" for stmt in node.body) for node in handlers
        ), "the restart handler must `continue` the outer iteration (#84733)"

    def test_restart_handler_clears_preflight_block(self):
        """The single consumer of restart_with_rebuilt_messages must clear
        _preflight_compression_blocked, so every retry-loop failover gets a
        fresh preflight against the fallback's context window (#84733)."""
        from agent import turn_iteration_prep

        tree = ast.parse(inspect.getsource(turn_iteration_prep.apply_retry_restarts))
        handlers = [
            node
            for node in ast.walk(tree)
            if isinstance(node, ast.If)
            and isinstance(node.test, ast.Attribute)
            and node.test.attr == "restart_with_rebuilt_messages"
        ]
        assert handlers, "expected the restart_with_rebuilt_messages handler"
        consumer = [
            node
            for node in handlers
            if any(
                isinstance(stmt, ast.Assign)
                and any(
                    isinstance(t, ast.Attribute)
                    and t.attr == "restart_with_rebuilt_messages"
                    for t in stmt.targets
                )
                for stmt in node.body
            )
        ]
        assert consumer, "expected the flag-consuming handler"
        for node in consumer:
            assert any(
                isinstance(stmt, ast.Assign)
                and any(
                    isinstance(t, ast.Name)
                    and t.id == "_preflight_compression_blocked"
                    for t in stmt.targets
                )
                and isinstance(stmt.value, ast.Constant)
                and stmt.value.value is False
                for stmt in node.body
            ), (
                "the restart handler must clear _preflight_compression_blocked "
                "so the re-run preflight isn't skipped (#84733)"
            )


class TestAuxFallbackReplanThreadsTtl:
    """#84733 follow-up: the auxiliary fallback replan path threads the
    configured tier too — it has no live agent, so it reads the same
    config key agent_init snapshots into ``agent._cache_ttl``."""

    def test_configured_cache_ttl_reads_valid_tiers(self, monkeypatch):
        import agent.agent_runtime_helpers as arh

        monkeypatch.setattr(
            "hermes_cli.config.load_config_readonly",
            lambda: {"prompt_caching": {"cache_ttl": "1h"}},
        )
        assert arh.configured_cache_ttl() == "1h"
        monkeypatch.setattr(
            "hermes_cli.config.load_config_readonly",
            lambda: {"prompt_caching": {"cache_ttl": "5m"}},
        )
        assert arh.configured_cache_ttl() == "5m"

    def test_configured_cache_ttl_none_for_disabled_or_unknown(self, monkeypatch):
        import agent.agent_runtime_helpers as arh

        for value in ("off", False, None, "2h"):
            monkeypatch.setattr(
                "hermes_cli.config.load_config_readonly",
                lambda value=value: {"prompt_caching": {"cache_ttl": value}},
            )
            assert arh.configured_cache_ttl() is None, value

    def test_replan_threads_configured_ttl_to_markers(self, monkeypatch):
        from agent import auxiliary_client

        monkeypatch.setattr(
            "hermes_cli.config.load_config_readonly",
            lambda: {"prompt_caching": {"cache_ttl": "1h"}},
        )
        destination = auxiliary_client._FallbackDestination(
            "anthropic",
            "https://api.anthropic.com",
            "anthropic_messages",
            "claude-opus-4.8",
        )
        messages = [
            {"role": "system", "content": "You are helpful."},
            {"role": "user", "content": "hello"},
        ]
        out_msgs, _ = auxiliary_client._replan_synchronous_cache_sections(
            messages, None, destination=destination
        )
        markers = _collect_cache_controls(out_msgs)
        assert markers, "expected cache_control markers on a caching route"
        assert all(m.get("ttl") == "1h" for m in markers), (
            "the configured 1h tier must reach auxiliary fallback replans"
        )
