"""Tests for the UTF-8 byte response budget.

The budget is the difference between an agent keeping its place and the MCP
client silently swapping a result to an opaque scratchpad file. These tests pin
the contract: minimal stubbing, honest truncation reporting, identity-gated
re-fetch hints, and never destroying non-recoverable computed values.
"""

from __future__ import annotations

import copy

import pytest

from a360_mcp.utils import response_budget as rb

# --------------------------------------------------------------------------- #
# Budget / measurement
# --------------------------------------------------------------------------- #


class TestBudgetConfig:
    def test_default_budget(self, monkeypatch):
        monkeypatch.delenv(rb.ENV_BUDGET, raising=False)
        assert rb.get_response_budget() == rb.DEFAULT_BUDGET_BYTES == 75_000

    def test_env_override(self, monkeypatch):
        monkeypatch.setenv(rb.ENV_BUDGET, "12345")
        assert rb.get_response_budget() == 12345

    def test_env_override_invalid_falls_back(self, monkeypatch):
        monkeypatch.setenv(rb.ENV_BUDGET, "not-a-number")
        assert rb.get_response_budget() == rb.DEFAULT_BUDGET_BYTES

    def test_env_override_zero_falls_back(self, monkeypatch):
        monkeypatch.setenv(rb.ENV_BUDGET, "0")
        assert rb.get_response_budget() == rb.DEFAULT_BUDGET_BYTES

    def test_byte_len_ascii(self):
        assert rb.byte_len({"a": "bc"}) == len(b'{"a":"bc"}')

    def test_byte_len_cjk_counts_bytes_not_chars(self):
        # Each CJK char is 3 UTF-8 bytes; orjson emits raw UTF-8 (no \u escapes).
        result = {"x": "字"}  # 1 CJK char = 3 bytes
        assert rb.byte_len(result) == len(b'{"x":"\xe5\xad\x97"}')

    def test_str_bytes(self):
        assert rb._str_bytes("字字") == 6


# --------------------------------------------------------------------------- #
# Identity detection
# --------------------------------------------------------------------------- #


class TestIdentityDetection:
    @pytest.mark.parametrize("key", ["id", "executionId", "fileId", "deploymentId", "activityId"])
    def test_container_identity_recognized(self, key):
        kind, value = rb._container_identity({key: "rec-123", "big": "x"})
        assert kind == key
        assert value == "rec-123"

    def test_container_identity_first_match_wins(self):
        # id is listed first; if both present, id wins.
        kind, _ = rb._container_identity({"executionId": "e1", "id": "i1"})
        assert kind == "id"

    def test_container_identity_empty_string_not_recognized(self):
        assert rb._container_identity({"id": "", "big": "x"}) == ("", "")

    def test_container_identity_non_string_ignored(self):
        assert rb._container_identity({"id": 5, "big": "x"}) == ("", "")

    def test_container_identity_missing(self):
        assert rb._container_identity({"big": "x"}) == ("", "")


# --------------------------------------------------------------------------- #
# Stub construction
# --------------------------------------------------------------------------- #


class TestStub:
    def test_stub_shape(self):
        value = "A" * 5000
        stub = rb._stub("content", value, "fileId", "f-1", "a360_get_bot")
        assert stub["_abridged"] is True
        assert stub["_full_length_bytes"] == 5000
        assert stub["_sha256"] == rb._sha256(value)
        assert stub["_preview"] == value[: rb.PREVIEW_CHARS]
        assert "content" in stub["_fetch"]
        assert "f-1" in stub["_fetch"]

    def test_fetch_hint_copy_pasteable_with_identity(self):
        hint = rb._build_fetch_hint("content", "executionId", "exec-9", "a360_tool")
        assert "content" in hint
        assert "exec-9" in hint
        assert "executionId" in hint

    def test_fetch_hint_without_identity(self):
        hint = rb._build_fetch_hint("content", "", "", "a360_tool")
        assert "content" in hint
        assert "a360_tool" in hint

    def test_preview_capped(self):
        value = "B" * 10_000
        stub = rb._stub("k", value, "id", "r", "t")
        assert len(stub["_preview"]) == rb.PREVIEW_CHARS


# --------------------------------------------------------------------------- #
# Eligibility / collect
# --------------------------------------------------------------------------- #


class TestEligibility:
    def _big(self, n=3000):
        return "X" * n

    def test_eligible_when_record_backed(self):
        obj = {"id": "r1", "content": self._big()}
        assert rb._collect_eligible(obj) == [3000]

    def test_not_eligible_without_identity(self):
        obj = {"content": self._big()}
        assert rb._collect_eligible(obj) == []

    def test_not_eligible_below_threshold(self):
        obj = {"id": "r1", "content": "X" * (rb.MIN_STUB_FIELD_BYTES - 1)}
        assert rb._collect_eligible(obj) == []

    def test_protected_key_skipped(self):
        obj = {"id": "r1", "payload": self._big()}
        assert rb._collect_eligible(obj) == []

    def test_nested_eligible(self):
        obj = {"id": "r1", "nested": {"fileId": "f1", "content": self._big(4000)}}
        assert rb._collect_eligible(obj) == [4000]

    def test_eligible_in_list(self):
        obj = {"rows": [{"id": "a", "content": self._big(3000)}, {"id": "b"}]}
        assert rb._collect_eligible(obj) == [3000]


# --------------------------------------------------------------------------- #
# enforce_response_budget — entry point
# --------------------------------------------------------------------------- #


class TestEnforceBasics:
    def test_small_result_unchanged(self):
        result = {"id": "r1", "name": "ok"}
        out, abridged = rb.enforce_response_budget(result, tool_name="t")
        assert out == result
        assert abridged is False

    def test_non_dict_non_list_unchanged(self):
        out, abridged = rb.enforce_response_budget("hello", tool_name="t")
        assert out == "hello"
        assert abridged is False

    def test_none_unchanged(self):
        out, abridged = rb.enforce_response_budget(None, tool_name="t")
        assert out is None
        assert abridged is False

    def test_does_not_mutate_input(self):
        original = {"id": "r1", "content": "X" * 80_000}
        snapshot = copy.deepcopy(original)
        rb.enforce_response_budget(original, tool_name="t", budget=5_000)
        assert original == snapshot


class TestEnforceStubbing:
    def _oversized(self):
        # One record-backed large field, well over default budget.
        return {"id": "r1", "content": "Q" * 80_000}

    def test_stubs_record_backed_large_field(self):
        out, abridged = rb.enforce_response_budget(self._oversized(), tool_name="t")
        assert abridged is True
        assert out["content"]["_abridged"] is True
        assert out["content"]["_full_length_bytes"] == 80_000
        assert "_abridged_fields" in out

    def test_leaves_protected_field_whole(self):
        # payload is protected & computed — even if huge, never stubbed.
        result = {"id": "r1", "payload": "Q" * 80_000}
        out, abridged = rb.enforce_response_budget(result, tool_name="t")
        assert abridged is False
        assert out == result

    def test_minimal_stubbing_binary_search(self):
        # Two large fields; only the largest need be stubbed to fit.
        result = {"id": "r1", "big": "A" * 50_000, "small": "B" * 30_000}
        out, abridged = rb.enforce_response_budget(result, tool_name="t", budget=60_000)
        assert abridged is True
        assert rb.byte_len(out) <= 60_000
        # big (50k) stubbed; small (30k) may or may not be — but total fits.
        assert out["big"]["_abridged"] is True

    def test_no_identity_no_stub(self):
        result = {"content": "Q" * 80_000}  # no id → not record-backed
        out, abridged = rb.enforce_response_budget(result, tool_name="t")
        assert abridged is False
        assert out == result


class TestEnforceRowTruncation:
    def test_truncates_record_list(self):
        rows = [{"id": f"r{i}", "name": "n" * 500} for i in range(200)]
        result = {"list": rows}
        out, abridged = rb.enforce_response_budget(result, tool_name="t", budget=10_000)
        assert abridged is True
        assert rb.byte_len(out) <= 10_000
        # Last element is the dropped marker.
        assert out["list"][-1].get("dropped_items", 0) > 0

    def test_does_not_truncate_computed_list(self):
        # No identity on items → not a record list → left whole (best effort).
        result = {"lines": ["x" * 200 for _ in range(200)]}
        out, abridged = rb.enforce_response_budget(result, tool_name="t", budget=5_000)
        # Nothing safely abridgeable → return whole.
        assert abridged is False
        assert out == result

    def test_dropped_items_reported_honestly(self):
        rows = [{"id": f"r{i}", "name": "n" * 800} for i in range(100)]
        result = {"rows": rows}
        out, abridged = rb.enforce_response_budget(result, tool_name="t", budget=8_000)
        assert abridged is True
        assert out["rows"][-1]["dropped_items"] > 0
        assert out.get("dropped_items", 0) > 0


class TestEnforceCombined:
    def test_stubbing_then_truncation(self):
        # Both a large field and many rows — stub first, then truncate rows.
        rows = [{"id": f"r{i}", "content": "C" * 1000} for i in range(60)]
        result = {"id": "root", "items": rows, "blob": "Z" * 60_000}
        out, abridged = rb.enforce_response_budget(result, tool_name="t", budget=20_000)
        assert abridged is True
        assert rb.byte_len(out) <= 20_000

    def test_result_fits_exactly_at_budget(self):
        result = {"id": "r1", "content": "A" * 1000}
        out, abridged = rb.enforce_response_budget(
            result, tool_name="t", budget=rb.byte_len(result)
        )
        assert abridged is False
        assert out == result


class TestProtectedKeys:
    def test_protected_key_listed(self):
        for key in ["verdict", "diagnosis", "prescription", "error", "message"]:
            assert key in rb.PROTECTED_KEYS

    def test_identity_keys_protected(self):
        for key in rb.IDENTITY_KEYS:
            assert key in rb.PROTECTED_KEYS

    def test_protected_at_any_depth(self):
        result = {
            "id": "r1",
            "nested": {"deep": {"verdict": "V" * 80_000}},
        }
        out, abridged = rb.enforce_response_budget(result, tool_name="t")
        assert abridged is False
        assert out["nested"]["deep"]["verdict"] == "V" * 80_000
