"""The anti-thrashing guard must actually fire.

Two defects made it dead code:

1. Effectiveness was scored as ``(current_tokens - estimate(compressed)) / current_tokens``.
   ``current_tokens`` is the provider's FULL prompt (system prompt + tool schemas +
   messages); ``estimate(compressed)`` covers messages only. The mixed basis reported
   ~96% savings on every pass, so ``_ineffective_compression_count`` reset each time.

2. Even scored correctly, message shrinkage is the wrong yardstick. ``should_compress()``
   trips on the full prompt, but compaction can only shrink messages. When the
   incompressible floor alone meets the threshold, each pass can shrink messages by a
   healthy margin, reset the counter, and still leave the prompt over the line -- so the
   next turn compacts again, forever.

Together these made a mis-sized context window present as a hung CLI rather than a
warning. Effectiveness is now scored against the goal -- did the prompt get under the
threshold? -- judged in ``update_from_response()``, the one place that sees the
provider's real prompt count for the just-compacted conversation.

Two subtleties this pins:

* The verdict must NOT live in ``should_compress()``. That runs twice per turn with
  two different measures (a rough preflight estimate and the real post-response count,
  #36718); the rough one can dip below the threshold and reset the strike every turn,
  re-opening the loop. ``test_rough_preflight_reading_does_not_reopen_the_loop``.

* It must not be computed analytically as ``floor = current_tokens - rough_estimate``.
  That subtracts an estimate from a real count, absorbs the tokenizer skew into "floor",
  and disables compaction on a healthy session.
  ``test_no_false_positive_under_tokenizer_skew``.
"""
import pytest

from agent.context_compressor import ContextCompressor


def _compressor(threshold_tokens: int) -> ContextCompressor:
    cc = ContextCompressor(
        model="test-model",
        threshold_percent=0.75,
        protect_first_n=5,
        protect_last_n=20,
        quiet_mode=True,
        config_context_length=40960,
        provider="test",
    )
    cc.threshold_tokens = threshold_tokens  # pin; don't couple to window math
    cc._generate_summary = lambda *a, **k: "Summary of earlier turns."
    return cc


def _messages(n: int, size: int = 1500) -> list:
    msgs = [{"role": "system", "content": "sys"}]
    for i in range(n):
        role = "user" if i % 2 == 0 else "assistant"
        msgs.append({"role": role, "content": f"m{i} " + "z" * size})
    return msgs


def _turn(cc, msgs, real_prompt_tokens):
    """One agent turn as conversation_loop drives it.

    should_compress() gates on the real prompt count; if it fires, compress()
    runs and the provider then reports real usage for the shorter conversation.
    The anti-thrashing verdict is judged in update_from_response() against that
    real count -- not inside should_compress(), which is called twice per turn
    with two different measures. Returns (msgs, did_compact).
    """
    if not cc.should_compress(real_prompt_tokens):
        return msgs, False
    msgs = cc.compress(msgs, current_tokens=real_prompt_tokens)
    cc._verify_compaction_cleared_threshold = True
    cc.update_from_response({"prompt_tokens": real_prompt_tokens})
    return msgs, True


class TestSavingsBasis:
    def test_savings_does_not_depend_on_current_tokens(self):
        """Savings is a property of the messages, not of the caller's token count.

        Before the fix, passing the full-prompt count made an identical compaction
        report a wildly higher savings percentage.
        """
        msgs = _messages(14)

        a = _compressor(threshold_tokens=24_576)
        a.compress([m.copy() for m in msgs], current_tokens=100_000)
        with_full_prompt = a._last_compression_savings_pct

        b = _compressor(threshold_tokens=24_576)
        b.compress([m.copy() for m in msgs], current_tokens=None)
        messages_only = b._last_compression_savings_pct

        assert with_full_prompt == pytest.approx(messages_only, abs=0.01), (
            "savings must be scored on a messages-vs-messages basis; got "
            f"{with_full_prompt:.1f}% with a full-prompt count vs "
            f"{messages_only:.1f}% without"
        )

    def test_no_op_compaction_is_not_reported_as_huge_savings(self):
        cc = _compressor(threshold_tokens=24_576)
        msgs = _messages(4, size=10)  # below the minimum compressible size
        cc.compress(msgs, current_tokens=100_000)
        assert cc._last_compression_savings_pct < 50


class TestFutilityGuard:
    def test_stops_when_floor_alone_meets_threshold(self):
        """Incompressible floor >= threshold -> shrinking messages cannot help."""
        cc = _compressor(threshold_tokens=24_576)
        msgs = _messages(14)
        # Full prompt is far above threshold and dominated by system + tools,
        # so the real count never drops no matter how the message list shrinks.
        real_prompt_tokens = 33_564

        fired = 0
        for _ in range(6):
            msgs, did = _turn(cc, msgs, real_prompt_tokens)
            if did:
                fired += 1
                msgs.append({"role": "user", "content": "next " + "w" * 4000})

        assert cc._ineffective_compression_count >= 2
        assert not cc.should_compress(real_prompt_tokens), (
            "compaction that cannot clear the threshold must stop"
        )
        assert fired <= 3, f"expected the loop to break early, compacted {fired}x"

    def test_rough_preflight_reading_does_not_reopen_the_loop(self):
        """should_compress() runs twice per turn with two different measures.

        The pre-API gate uses a rough estimate that can dip below the threshold;
        the post-response gate uses the real count that does not. If the verdict
        lived in should_compress(), the rough reading would reset the strike
        every turn and the loop would never stop. Judging it in
        update_from_response() (real-vs-real) closes that hole.
        """
        cc = _compressor(threshold_tokens=24_576)
        msgs = _messages(13)
        rough, real = 20_000, 33_564  # rough dips under; real never does

        fired = 0
        for _ in range(8):
            cc.should_compress(rough)          # pre-API gate (rough)
            msgs, did = _turn(cc, msgs, real)  # post-response gate (real) + usage
            if did:
                fired += 1
                msgs.append({"role": "user", "content": "more " + "w" * 3000})

        assert fired <= 2, (
            f"a sub-threshold rough reading must not re-open the loop; "
            f"compacted {fired}x"
        )

    def test_effective_compaction_still_resets_the_counter(self):
        """A compaction that gets the prompt under the threshold is not thrashing."""
        cc = _compressor(threshold_tokens=750_000)
        msgs = _messages(120, size=2000)
        before = len(msgs)
        out = cc.compress(msgs, current_tokens=None)

        assert len(out) < before, "a long transcript must still compact"
        assert cc._last_compression_savings_pct > 10
        assert cc._last_compression_made_progress is True
        assert cc._ineffective_compression_count == 0

    def test_no_false_positive_under_tokenizer_skew(self):
        """The provider's real count exceeds the rough estimate; that is not a floor.

        An analytic `floor = real_prompt - rough_estimate` misreads tokenizer skew
        as incompressible overhead and disables compaction on a healthy session.
        The check must compare the caller's own measure on both sides.
        """
        from agent.context_compressor import estimate_messages_tokens_rough

        skew, floor = 1.6, 30_000
        cc = _compressor(threshold_tokens=150_000)
        msgs = _messages(160, size=2500)

        real_prompt = floor + int(skew * estimate_messages_tokens_rough(msgs))
        assert cc.should_compress(real_prompt)
        msgs = cc.compress(msgs, current_tokens=real_prompt)
        real_after = floor + int(skew * estimate_messages_tokens_rough(msgs))
        cc._verify_compaction_cleared_threshold = True
        cc.update_from_response({"prompt_tokens": real_after})  # provider's real count

        assert real_after < cc.threshold_tokens, "compaction really did clear it"
        assert cc._ineffective_compression_count == 0, (
            "tokenizer skew must not be mistaken for an incompressible floor"
        )

    def test_latched_counter_resets_after_any_real_prompt_fits(self):
        cc = _compressor(threshold_tokens=24_576)
        cc._ineffective_compression_count = 2

        cc.update_from_response({"prompt_tokens": 20_000})

        assert cc._ineffective_compression_count == 0
        assert cc.should_compress(33_564)

    def test_usage_less_response_consumes_pending_verdict(self):
        cc = _compressor(threshold_tokens=24_576)
        cc._verify_compaction_cleared_threshold = True
        cc.awaiting_real_usage_after_compression = True

        cc.update_from_response({})

        assert cc._verify_compaction_cleared_threshold is False
        assert cc.awaiting_real_usage_after_compression is False
        assert cc._ineffective_compression_count == 0

    def test_fallback_streak_survives_ordinary_fitting_responses(self):
        cc = _compressor(threshold_tokens=24_576)

        cc.record_completed_compaction(used_fallback=True)
        cc.update_from_response({"prompt_tokens": 20_000})
        assert cc._fallback_compression_streak == 1

        # Context regrows through ordinary successful turns before the next
        # fallback boundary. Those turns reset real-usage effectiveness, not
        # the independent summary-quality breaker.
        cc.update_from_response({"prompt_tokens": 20_000})
        cc.record_completed_compaction(used_fallback=True)
        cc.update_from_response({"prompt_tokens": 20_000})

        assert cc._fallback_compression_streak == 2
        assert not cc.should_compress(33_564)

    def test_usage_less_fallback_boundary_still_counts(self):
        cc = _compressor(threshold_tokens=24_576)

        cc.record_completed_compaction(used_fallback=True)
        cc.awaiting_real_usage_after_compression = True
        cc.update_from_response({})

        assert cc._fallback_compression_streak == 1
        assert cc._verify_compaction_cleared_threshold is False
        assert cc.awaiting_real_usage_after_compression is False

    def test_healthy_boundary_resets_only_fallback_streak(self):
        cc = _compressor(threshold_tokens=24_576)
        cc.record_completed_compaction(used_fallback=True)
        cc.record_completed_compaction(used_fallback=False)

        assert cc._fallback_compression_streak == 0
        assert cc._verify_compaction_cleared_threshold is True

    def test_model_switch_resets_and_persists_fallback_streak(self, tmp_path):
        from hermes_state import SessionDB

        db = SessionDB(db_path=tmp_path / "state.db")
        db.create_session("s1", source="cli")
        cc = _compressor(threshold_tokens=24_576)
        cc.bind_session_state(db, "s1")
        cc.record_completed_compaction(used_fallback=True)

        cc.update_model("next-model", 100_000)

        assert cc._fallback_compression_streak == 0
        assert db.get_compression_fallback_streak("s1") == 0

    def test_same_runtime_context_recalibration_preserves_fallback_streak(self, tmp_path):
        from hermes_state import SessionDB

        db = SessionDB(db_path=tmp_path / "state.db")
        db.create_session("s1", source="cli")
        cc = _compressor(threshold_tokens=24_576)
        cc.bind_session_state(db, "s1")
        cc.record_completed_compaction(used_fallback=True)

        cc.update_model(cc.model, 64_000, provider=cc.provider)

        assert cc._fallback_compression_streak == 1
        assert db.get_compression_fallback_streak("s1") == 1

    def test_a_failed_pass_records_exactly_one_strike(self):
        """A compaction that leaves the real prompt over the threshold: one strike.

        The verdict is judged once, when the provider reports real usage — not on
        every should_compress() reading.
        """
        cc = _compressor(threshold_tokens=24_576)
        msgs = _messages(14)

        assert cc.should_compress(33_564)
        cc.compress(msgs, current_tokens=33_564)
        cc._verify_compaction_cleared_threshold = True
        assert cc._ineffective_compression_count == 0, "no verdict before real usage"

        cc.update_from_response({"prompt_tokens": 33_564})  # still over
        assert cc._ineffective_compression_count == 1

        # A later reading, rough or real, must not add phantom strikes.
        cc.should_compress(33_564)
        cc.should_compress(20_000)
        assert cc._ineffective_compression_count == 1


class TestMinimumMessagesBranch:
    def test_too_few_messages_records_an_ineffective_pass(self):
        """Returning the transcript unchanged must move the anti-thrash state.

        Otherwise should_compress() keeps saying True about a transcript that can
        never shrink, and every turn re-enters a no-op compaction.
        """
        cc = _compressor(threshold_tokens=1)
        msgs = _messages(3, size=10)
        before = cc._ineffective_compression_count

        out = cc.compress(msgs, current_tokens=100_000)

        assert len(out) == len(msgs), "nothing should have been compressed"
        assert cc._last_compression_made_progress is False
        assert cc._ineffective_compression_count == before + 1
