"""Tests for prompt caching utilities."""

import pytest

from agent_preflight.caching import (
    ANTHROPIC_MIN_CACHE_TOKENS,
    CacheMetrics,
    PromptBuilder,
    detect_provider,
    format_cache_report,
    min_cache_tokens,
    stable_system_prompt,
)


# ---------------------------------------------------------------------------
# CacheMetrics
# ---------------------------------------------------------------------------


class TestCacheMetrics:
    def test_empty(self):
        m = CacheMetrics()
        assert m.hit_rate == 0.0
        assert m.request_hit_rate == 0.0
        assert m.summary() == "Cache: no requests recorded"

    def test_record_openai_style(self):
        m = CacheMetrics()
        m.record({
            "prompt_tokens": 2000,
            "completion_tokens": 100,
            "prompt_tokens_details": {"cached_tokens": 1500},
        })
        assert m.total_prompt_tokens == 2000
        assert m.cached_tokens == 1500
        assert m.cache_hit_requests == 1
        assert m.hit_rate == pytest.approx(0.75)

    def test_record_anthropic_style(self):
        m = CacheMetrics()
        m.record({
            "prompt_tokens": 2000,
            "completion_tokens": 100,
            "cache_read_input_tokens": 1800,
            "cache_creation_input_tokens": 200,
        })
        assert m.cached_tokens == 1800
        assert m.cache_creation_tokens == 200
        assert m.cache_hit_requests == 1

    def test_record_openrouter_normalized(self):
        """OpenRouter normalizes cache fields into prompt_tokens_details."""
        m = CacheMetrics()
        m.record({
            "prompt_tokens": 10339,
            "completion_tokens": 60,
            "prompt_tokens_details": {
                "cached_tokens": 10318,
                "cache_write_tokens": 0,
            },
        })
        assert m.cached_tokens == 10318
        assert m.cache_creation_tokens == 0
        assert m.cache_hit_requests == 1

    def test_record_openrouter_cache_write(self):
        """OpenRouter cache_write_tokens tracked as creation tokens."""
        m = CacheMetrics()
        m.record({
            "prompt_tokens": 5000,
            "prompt_tokens_details": {
                "cached_tokens": 0,
                "cache_write_tokens": 4500,
            },
        })
        assert m.cached_tokens == 0
        assert m.cache_creation_tokens == 4500
        assert m.cache_hit_requests == 0

    def test_record_no_cache(self):
        m = CacheMetrics()
        m.record({"prompt_tokens": 500, "completion_tokens": 50})
        assert m.cached_tokens == 0
        assert m.cache_hit_requests == 0
        assert m.request_count == 1

    def test_record_null_details(self):
        """prompt_tokens_details can be None in some responses."""
        m = CacheMetrics()
        m.record({"prompt_tokens": 500, "prompt_tokens_details": None})
        assert m.cached_tokens == 0

    def test_record_warns_on_missing_cache_fields(self, caplog):
        """Warn when prompt tokens present but no cache data at all."""
        import logging
        m = CacheMetrics()
        with caplog.at_level(logging.WARNING, logger="agent_preflight.caching"):
            m.record({"prompt_tokens": 1000, "completion_tokens": 50})
        assert "no cache data" in caplog.text

    def test_record_no_warn_when_cache_fields_present(self, caplog):
        """No warning when cache fields exist (even if 0 cached tokens)."""
        import logging
        m = CacheMetrics()
        with caplog.at_level(logging.WARNING, logger="agent_preflight.caching"):
            m.record({"prompt_tokens": 1000, "cache_read_input_tokens": 0})
        assert "no cache data" not in caplog.text

    def test_record_no_warn_when_cache_write_tokens_present(self, caplog):
        """No warning when OpenRouter cache_write_tokens field exists."""
        import logging
        m = CacheMetrics()
        with caplog.at_level(logging.WARNING, logger="agent_preflight.caching"):
            m.record({"prompt_tokens": 1000, "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 500}})
        assert "no cache data" not in caplog.text

    def test_accumulation_across_requests(self):
        m = CacheMetrics()
        m.record({"prompt_tokens": 1000, "prompt_tokens_details": {"cached_tokens": 500}})
        m.record({"prompt_tokens": 1000, "prompt_tokens_details": {"cached_tokens": 800}})
        m.record({"prompt_tokens": 1000, "prompt_tokens_details": {"cached_tokens": 0}})
        assert m.request_count == 3
        assert m.cached_tokens == 1300
        assert m.cache_hit_requests == 2  # third had 0 cached
        assert m.total_prompt_tokens == 3000
        assert m.hit_rate == pytest.approx(1300 / 3000)

    def test_estimated_savings(self):
        m = CacheMetrics()
        m.record({"prompt_tokens": 1_000_000, "prompt_tokens_details": {"cached_tokens": 500_000}})
        # $3 per million prompt tokens, 50% discount on cached
        savings = m.estimated_savings_usd(prompt_cost_per_m=3.0, cache_discount=0.5)
        # 500k tokens * ($3/1M) * (1 - 0.5) = 500k * 0.000003 * 0.5 = $0.75
        assert savings == pytest.approx(0.75)

    def test_summary_format(self):
        m = CacheMetrics()
        m.record({"prompt_tokens": 2000, "prompt_tokens_details": {"cached_tokens": 1700}})
        s = m.summary()
        assert "85.0%" in s
        assert "1,700" in s
        assert "1/1 requests hit" in s

    def test_to_dict(self):
        m = CacheMetrics()
        m.record({"prompt_tokens": 100, "cache_read_input_tokens": 80})
        d = m.to_dict()
        assert d["cached_tokens"] == 80
        assert d["hit_rate"] == 0.8  # 80/100 prompt tokens cached
        # request_hit_rate = cache_hit_requests / request_count = 1/1
        assert d["request_hit_rate"] == 1.0


# ---------------------------------------------------------------------------
# PromptBuilder
# ---------------------------------------------------------------------------


class TestPromptBuilder:
    def test_build_openai_basic(self):
        b = PromptBuilder(system="You are helpful.", provider="openai")
        b.add_user("Hello")
        payload = b.build(model="openai/gpt-4o")
        assert payload["model"] == "openai/gpt-4o"
        assert payload["messages"][0] == {"role": "system", "content": "You are helpful."}
        assert payload["messages"][1] == {"role": "user", "content": "Hello"}

    def test_build_with_environment(self):
        b = PromptBuilder(system="System.", environment="cwd: /app", provider="openai")
        payload = b.build(model="openai/gpt-4o")
        assert "cwd: /app" in payload["messages"][0]["content"]

    def test_build_with_tools(self):
        tools = [{"type": "function", "function": {"name": "bash"}}]
        b = PromptBuilder(system="S", tools=tools, provider="openai")
        payload = b.build(model="openai/gpt-4o")
        assert payload["tools"] == tools

    def test_build_no_tools_omits_key(self):
        b = PromptBuilder(system="S", provider="openai")
        payload = b.build(model="openai/gpt-4o")
        assert "tools" not in payload

    def test_build_max_tokens(self):
        b = PromptBuilder(system="S", provider="openai")
        payload = b.build(model="openai/gpt-4o", max_tokens=1024)
        assert payload["max_tokens"] == 1024

    def test_build_extra_params(self):
        b = PromptBuilder(system="S", provider="openai")
        payload = b.build(model="openai/gpt-4o", temperature=0.0)
        assert payload["temperature"] == 0.0

    def test_anthropic_default_mode_uses_explicit_cache_control(self):
        """Default Anthropic mode uses explicit per-block cache_control."""
        b = PromptBuilder(system="You are helpful.", provider="anthropic")
        b.add_user("Hello")
        payload = b.build(model="anthropic/claude-sonnet-4")
        sys_msg = payload["messages"][0]
        assert sys_msg["role"] == "system"
        assert isinstance(sys_msg["content"], list)
        assert sys_msg["content"][0]["cache_control"] == {"type": "ephemeral"}
        assert "cache_control" not in payload

    def test_anthropic_default_mode_with_ttl(self):
        """Default explicit mode carries TTL on the system block."""
        b = PromptBuilder(system="S", provider="anthropic", cache_ttl="1h")
        b.add_user("Hi")
        payload = b.build(model="anthropic/claude-sonnet-4")
        sys_msg = payload["messages"][0]
        assert sys_msg["content"][0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}

    def test_anthropic_auto_mode_top_level_cache_control(self):
        """Opt-in auto mode adds top-level cache_control."""
        b = PromptBuilder(system="You are helpful.", provider="anthropic", anthropic_mode="auto")
        b.add_user("Hello")
        payload = b.build(model="anthropic/claude-sonnet-4")
        sys_msg = payload["messages"][0]
        assert sys_msg["role"] == "system"
        assert isinstance(sys_msg["content"], str)
        assert payload["cache_control"] == {"type": "ephemeral"}

    def test_anthropic_explicit_system_has_cache_control(self):
        """Explicit mode puts cache_control on system content block."""
        b = PromptBuilder(system="You are helpful.", provider="anthropic", anthropic_mode="explicit")
        b.add_user("Hello")
        payload = b.build(model="anthropic/claude-sonnet-4")
        sys_msg = payload["messages"][0]
        assert sys_msg["role"] == "system"
        assert isinstance(sys_msg["content"], list)
        assert sys_msg["content"][0]["cache_control"] == {"type": "ephemeral"}
        # Explicit mode should NOT have top-level cache_control
        assert "cache_control" not in payload

    def test_anthropic_explicit_breakpoint_on_second_to_last_user(self):
        """Explicit mode places breakpoint on second-to-last user message."""
        b = PromptBuilder(system="S", provider="anthropic", anthropic_mode="explicit")
        b.add_user("Turn 1")
        b.add_assistant("Reply 1")
        b.add_user("Turn 2")
        b.add_assistant("Reply 2")
        b.add_user("Turn 3")
        payload = b.build(model="anthropic/claude-sonnet-4")
        # Messages: system, user1, assistant1, user2, assistant2, user3
        msgs = payload["messages"]
        # user2 is at index 3
        user2 = msgs[3]
        assert isinstance(user2["content"], list)
        assert user2["content"][0].get("cache_control") == {"type": "ephemeral"}
        # user3 (last) should NOT have cache_control
        user3 = msgs[5]
        assert isinstance(user3["content"], str)

    def test_anthropic_explicit_with_ttl(self):
        """Explicit mode with 1-hour TTL."""
        b = PromptBuilder(system="S", provider="anthropic", anthropic_mode="explicit", cache_ttl="1h")
        b.add_user("Hello")
        payload = b.build(model="anthropic/claude-sonnet-4")
        sys_msg = payload["messages"][0]
        assert sys_msg["content"][0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}

    def test_append_only_does_not_mutate(self):
        """Messages added should not be mutated by build()."""
        b = PromptBuilder(system="S", provider="anthropic", anthropic_mode="explicit")
        b.add_user("Hello")
        b.add_assistant("Hi")
        b.add_user("Bye")
        # build should deep-copy, not modify originals
        b.build(model="anthropic/claude-sonnet-4")
        # Original messages should still be plain strings
        assert isinstance(b._messages[0]["content"], str)
        assert b._messages[0]["content"] == "Hello"

    def test_auto_provider_anthropic(self):
        b = PromptBuilder(system="S")
        b.add_user("Hi")
        payload = b.build(model="anthropic/claude-sonnet-4")
        sys_msg = payload["messages"][0]
        assert isinstance(sys_msg["content"], list)
        assert sys_msg["content"][0]["cache_control"] == {"type": "ephemeral"}
        assert "cache_control" not in payload

    def test_auto_provider_openai(self):
        b = PromptBuilder(system="S")
        b.add_user("Hi")
        payload = b.build(model="openai/gpt-4o")
        sys_msg = payload["messages"][0]
        assert isinstance(sys_msg["content"], str)

    def test_turn_count(self):
        b = PromptBuilder(system="S")
        assert b.turn_count == 0
        b.add_user("1")
        b.add_assistant("r1")
        b.add_user("2")
        assert b.turn_count == 2

    def test_add_tool_result(self):
        b = PromptBuilder(system="S", provider="openai")
        b.add_user("Call bash")
        b.add_tool_result("call_123", "output here")
        payload = b.build(model="openai/gpt-4o")
        tool_msg = payload["messages"][2]
        assert tool_msg["role"] == "tool"
        assert tool_msg["tool_call_id"] == "call_123"
        assert tool_msg["content"] == "output here"

    def test_add_message_arbitrary(self):
        b = PromptBuilder(system="S", provider="openai")
        b.add_message({"role": "user", "content": [{"type": "image_url", "url": "..."}]})
        payload = b.build(model="openai/gpt-4o")
        assert payload["messages"][1]["role"] == "user"

    def test_metrics_instance(self):
        b = PromptBuilder(system="S")
        assert isinstance(b.metrics, CacheMetrics)
        b.metrics.record({"prompt_tokens": 100})
        assert b.metrics.request_count == 1


# ---------------------------------------------------------------------------
# detect_provider
# ---------------------------------------------------------------------------


class TestDetectProvider:
    @pytest.mark.parametrize("model,expected", [
        ("anthropic/claude-sonnet-4", "anthropic"),
        ("anthropic/claude-haiku-4", "anthropic"),
        ("openai/gpt-4o", "openai"),
        ("openai/gpt-4o-mini", "openai"),
        ("google/gemini-flash-1.5", "google"),
        ("google/gemini-pro", "google"),
        ("meta-llama/llama-3.3-70b-instruct", "other"),
        ("mistralai/mistral-large", "other"),
    ])
    def test_detection(self, model, expected):
        assert detect_provider(model) == expected


# ---------------------------------------------------------------------------
# stable_system_prompt
# ---------------------------------------------------------------------------


class TestStableSystemPrompt:
    def test_joins_sections(self):
        result = stable_system_prompt("Part 1.", "Part 2.", "Part 3.")
        assert result == "Part 1.\n\nPart 2.\n\nPart 3."

    def test_strips_whitespace(self):
        result = stable_system_prompt("  Part 1.  ", "  Part 2.  ")
        assert result == "Part 1.\n\nPart 2."

    def test_skips_empty(self):
        result = stable_system_prompt("A", "", None, "B")
        assert result == "A\n\nB"


# ---------------------------------------------------------------------------
# format_cache_report
# ---------------------------------------------------------------------------


class TestFormatCacheReport:
    def test_empty_metrics(self):
        report = format_cache_report(CacheMetrics())
        assert "no requests recorded" in report

    def test_with_data(self):
        m = CacheMetrics()
        m.record({"prompt_tokens": 2000, "cache_read_input_tokens": 1700, "cache_creation_input_tokens": 300})
        report = format_cache_report(m, model_id="anthropic/claude-sonnet-4")
        assert "anthropic/claude-sonnet-4" in report
        assert "85.0%" in report
        assert "300" in report  # creation tokens

    def test_no_creation_tokens_line_when_zero(self):
        m = CacheMetrics()
        m.record({"prompt_tokens": 100, "prompt_tokens_details": {"cached_tokens": 50}})
        report = format_cache_report(m)
        assert "Cache writes" not in report


# ---------------------------------------------------------------------------
# min_cache_tokens
# ---------------------------------------------------------------------------


class TestMinCacheTokens:
    def test_known_anthropic_model(self):
        assert min_cache_tokens("anthropic/claude-opus-4.6") == 4096
        assert min_cache_tokens("anthropic/claude-opus-4-6") == 4096
        assert min_cache_tokens("anthropic/claude-sonnet-4") == 1024
        assert min_cache_tokens("anthropic/claude-sonnet-4.6") == 2048

    def test_legacy_hyphenated_anthropic_aliases_still_work(self):
        assert min_cache_tokens("anthropic/claude-sonnet-4-6") == 2048
        assert min_cache_tokens("anthropic/claude-opus-4-1") == 1024

    def test_openai_fallback(self):
        assert min_cache_tokens("openai/gpt-4o") == 1024

    def test_google_fallback(self):
        assert min_cache_tokens("google/gemini-pro") == 4096

    def test_unknown_fallback(self):
        assert min_cache_tokens("meta-llama/llama-3.3-70b-instruct") == 1024

    def test_constants_dict_has_entries(self):
        assert len(ANTHROPIC_MIN_CACHE_TOKENS) >= 9
