"""Tests for the Genosis SDK client.

Covers the complete SDK v1 contract:
1. Hash — SHA-256 block identification
2. Optimize — manifest application with cache_control
3. Report — telemetry with blocks + raw usage, no cost math
4. Refresh — manifest fetch + ack + optimization trigger
Plus: memoization, error reporting, provider detection, safety guarantees.
"""

from __future__ import annotations

import time
from typing import Any
from unittest.mock import MagicMock, patch, call

import pytest

from genosis import Genosis, MemoStorage, InMemoryMemoStorage
from genosis.client import _sha256


# ── Helpers ───────────────────────────────────────────────────────────────────

import os
import tempfile

_test_counter = 0

def make_client(**kwargs) -> Genosis:
    global _test_counter
    _test_counter += 1
    defaults = {
        "api_key": "gns_test_abc123",
        "base_url": "http://localhost:3001",
        "manifest_refresh_interval": 0,  # disable auto-refresh for deterministic tests
        "buffer_path": os.path.join(tempfile.gettempdir(), f"genosis_test_{os.getpid()}_{_test_counter}.db"),
    }
    defaults.update(kwargs)
    g = Genosis(**defaults)
    # Stop the background worker for deterministic tests — we inspect the buffer directly
    g._worker.shutdown(timeout=0.5)
    return g


def make_http_response(data: Any, status: int = 200, headers: dict | None = None):
    from genosis.http import HttpResponse
    import httpx
    return HttpResponse(data, status, httpx.Headers(headers or {}))


def mock_http(data: Any = None, status: int = 200, headers: dict | None = None) -> MagicMock:
    m = MagicMock()
    resp = make_http_response(data or {}, status, headers)
    m.get.return_value = resp
    m.post.return_value = resp
    m.delete.return_value = resp
    return m


def capture_buffer(g: Genosis) -> list[dict]:
    """Read all events from the client's buffer."""
    return g._buffer.peek(10000)


def capture_http() -> tuple[MagicMock, dict]:
    """Returns a mock HTTP client that captures all POST bodies."""
    m = MagicMock()
    captured: dict[str, list] = {"posts": []}

    def capture_post(path, body=None):
        captured["posts"].append({"path": path, "body": body})
        return make_http_response({"accepted": 1})

    m.post.side_effect = capture_post
    m.get.return_value = make_http_response({})
    return m, captured


# ── Constructor ───────────────────────────────────────────────────────────────

class TestConstructor:
    def test_rejects_missing_key(self):
        with pytest.raises((ValueError, TypeError)):
            Genosis(api_key="")

    def test_rejects_invalid_key_format(self):
        with pytest.raises(ValueError, match="Invalid API key"):
            Genosis(api_key="sk-not-a-genosis-key")

    def test_accepts_live_key(self):
        assert make_client(api_key="gns_live_abc123") is not None

    def test_accepts_test_key(self):
        assert make_client(api_key="gns_test_abc123") is not None

    def test_rejects_http_non_localhost(self):
        with pytest.raises(ValueError, match="HTTPS"):
            Genosis(api_key="gns_test_abc123", base_url="http://example.com")

    def test_has_sub_clients(self):
        g = make_client()
        assert hasattr(g, "account")
        assert hasattr(g, "manifest")
        assert hasattr(g, "optimization")
        assert hasattr(g, "telemetry")

    def test_worker_id_generated(self):
        g = make_client()
        assert g._worker_id is not None
        assert len(g._worker_id) > 10
        # Format: hostname-pid-random
        parts = g._worker_id.split("-")
        assert len(parts) >= 3

    def test_worker_id_unique_per_instance(self):
        g1 = make_client()
        g2 = make_client()
        assert g1._worker_id != g2._worker_id

    def test_worker_id_in_telemetry(self):
        g = make_client()
        g._queue_telemetry(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            {"usage": {"input_tokens": 10, "output_tokens": 5}},
        )
        events = capture_buffer(g)
        assert events[0]["payload"]["worker_id"] == g._worker_id

    def test_worker_id_in_memoized_telemetry(self):
        g = make_client()
        g._queue_memoized_telemetry(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            [], "fp_xyz", 50,
        )
        events = capture_buffer(g)
        assert events[0]["payload"]["worker_id"] == g._worker_id

    def test_flush_returns_zero_on_empty_buffer(self):
        g = make_client()
        remaining = g.flush(timeout=1)
        assert remaining == 0

    def test_flush_drains_buffer(self):
        g = make_client()
        g._buffer.put("telemetry", {"event_id": "e1"})
        g._buffer.put("telemetry", {"event_id": "e2"})
        # Worker is shut down in make_client, so events stay in buffer
        assert g._buffer.size() == 2
        # Flush won't drain without worker, but should return count
        remaining = g.flush(timeout=1)
        assert remaining == 2  # Worker stopped, so not drained

    def test_memo_storage_default(self):
        g = make_client(memoization_enabled=True)
        assert isinstance(g._memo_storage, InMemoryMemoStorage)

    def test_memo_storage_custom(self):
        class FakeStorage(MemoStorage):
            def get(self, fp): return None
            def set(self, fp, resp, ttl): pass
        storage = FakeStorage()
        g = make_client(memoization_enabled=True, memo_storage=storage)
        assert g._memo_storage is storage

    def test_memo_enabled_by_default(self):
        g = make_client()
        assert isinstance(g._memo_storage, InMemoryMemoStorage)


# ── Provider detection ────────────────────────────────────────────────────────

class TestProviderDetection:
    def test_anthropic_by_prefix(self):
        g = make_client()
        assert g._detect_provider({"model": "claude-sonnet-4-6"}) == "anthropic"

    def test_anthropic_by_system_key(self):
        g = make_client()
        assert g._detect_provider({"system": [], "model": "some-anthropic-model"}) == "anthropic"

    def test_openai_gpt(self):
        g = make_client()
        assert g._detect_provider({"messages": [], "model": "gpt-4o"}) == "openai"

    def test_openai_o1(self):
        g = make_client()
        assert g._detect_provider({"messages": [], "model": "o1-mini"}) == "openai"

    def test_openai_o3(self):
        g = make_client()
        assert g._detect_provider({"messages": [], "model": "o3"}) == "openai"

    def test_openai_o4(self):
        g = make_client()
        assert g._detect_provider({"messages": [], "model": "o4-mini"}) == "openai"

    def test_openai_o2(self):
        g = make_client()
        assert g._detect_provider({"messages": [], "model": "o2"}) == "openai"

    def test_openai_future_o_series(self):
        g = make_client()
        assert g._detect_provider({"messages": [], "model": "o5"}) == "openai"
        assert g._detect_provider({"messages": [], "model": "o10-mini"}) == "openai"

    def test_google_by_prefix(self):
        g = make_client()
        assert g._detect_provider({"model": "gemini-2.5-flash"}) == "google"

    def test_google_by_system_instruction(self):
        g = make_client()
        assert g._detect_provider({"systemInstruction": {}, "model": "some-google-model"}) == "google"

    def test_verygoodai(self):
        g = make_client()
        assert g._detect_provider({"system": [], "model": "vgai-sonnet"}) == "verygoodai"

    def test_verygoodai_precedence_over_system_key(self):
        g = make_client()
        assert g._detect_provider({"system": [], "model": "vgai-4o"}) == "verygoodai"

    def test_unknown_raises(self):
        g = make_client()
        with pytest.raises(ValueError, match="Unrecognized"):
            g._detect_provider({"model": "unknown-xyz"})

    def test_unknown_no_model_raises(self):
        g = make_client()
        with pytest.raises(ValueError, match="Unrecognized"):
            g._detect_provider({"messages": []})

    def test_provider_map_overrides_prefix(self):
        from genosis import Genosis
        g = Genosis(
            api_key="gns_test_abc123",
            buffer_path="/tmp/genosis_test_providermap.db",
            provider_map={"my-gpt4-deployment": "openai", "my-claude-prod": "anthropic"},
        )
        g._worker.shutdown(0)
        assert g._detect_provider({"model": "my-gpt4-deployment"}) == "openai"
        assert g._detect_provider({"model": "my-claude-prod"}) == "anthropic"

    def test_default_provider_resolves_azure_deployments(self):
        from genosis import Genosis
        g = Genosis(
            api_key="gns_test_abc123",
            buffer_path="/tmp/genosis_test_defaultprovider.db",
            default_provider="openai",
        )
        g._worker.shutdown(0)
        assert g._detect_provider({"model": "company-prod-gpt4"}) == "openai"
        assert g._detect_provider({"model": "azure-deployment-v2"}) == "openai"

    def test_provider_map_takes_precedence_over_default(self):
        from genosis import Genosis
        g = Genosis(
            api_key="gns_test_abc123",
            buffer_path="/tmp/genosis_test_mapvsdefault.db",
            provider_map={"my-claude-deployment": "anthropic"},
            default_provider="openai",
        )
        g._worker.shutdown(0)
        assert g._detect_provider({"model": "my-claude-deployment"}) == "anthropic"
        assert g._detect_provider({"model": "any-other"}) == "openai"

    def test_error_message_mentions_provider_map(self):
        g = make_client()
        with pytest.raises(ValueError, match="provider_map"):
            g._detect_provider({"model": "company-deployment"})


# ── Block extraction (format-based, not provider-based) ──────────────────────

class TestExtractBlocks:
    def test_anthropic_string_system(self):
        g = make_client()
        blocks = g._extract_blocks({"system": "Hello world"}, "anthropic")
        assert len(blocks) == 1
        assert blocks[0]["hash"] == _sha256("Hello world")
        assert blocks[0]["position"] == 0
        assert blocks[0]["cached"] is False

    def test_anthropic_array_system(self):
        g = make_client()
        blocks = g._extract_blocks({
            "system": [
                {"type": "text", "text": "Block one"},
                {"type": "text", "text": "Block two", "cache_control": {"type": "ephemeral"}},
            ],
        }, "anthropic")
        assert len(blocks) == 2
        # Both blocks are in the cached segment (breakpoint at block 1 caches 0+1)
        assert blocks[0]["cached"] is True
        assert blocks[1]["cached"] is True

    def test_cached_segment_marks_all_blocks(self):
        """All blocks before a cache_control breakpoint are part of the cached segment."""
        g = make_client()
        blocks = g._extract_blocks({
            "system": [
                {"type": "text", "text": "A"},
                {"type": "text", "text": "B"},
                {"type": "text", "text": "C", "cache_control": {"type": "ephemeral"}},
                {"type": "text", "text": "D"},
                {"type": "text", "text": "E"},
            ],
        }, "anthropic")
        assert len(blocks) == 5
        assert blocks[0]["cached"] is True   # in segment 1
        assert blocks[1]["cached"] is True   # in segment 1
        assert blocks[2]["cached"] is True   # breakpoint of segment 1
        assert blocks[3]["cached"] is False  # after last breakpoint
        assert blocks[4]["cached"] is False  # after last breakpoint

    def test_two_segments(self):
        """Two breakpoints create two cached segments."""
        g = make_client()
        blocks = g._extract_blocks({
            "system": [
                {"type": "text", "text": "A"},
                {"type": "text", "text": "B", "cache_control": {"type": "ephemeral"}},
                {"type": "text", "text": "C"},
                {"type": "text", "text": "D", "cache_control": {"type": "ephemeral"}},
                {"type": "text", "text": "E"},
            ],
        }, "anthropic")
        assert blocks[0]["cached"] is True   # segment 1
        assert blocks[1]["cached"] is True   # breakpoint 1
        assert blocks[2]["cached"] is True   # segment 2
        assert blocks[3]["cached"] is True   # breakpoint 2
        assert blocks[4]["cached"] is False  # after last breakpoint

    def test_no_breakpoints_nothing_cached(self):
        """Without any cache_control markers, nothing is cached."""
        g = make_client()
        blocks = g._extract_blocks({
            "system": [
                {"type": "text", "text": "A"},
                {"type": "text", "text": "B"},
            ],
        }, "anthropic")
        assert blocks[0]["cached"] is False
        assert blocks[1]["cached"] is False

    def test_openai_system_messages(self):
        g = make_client()
        blocks = g._extract_blocks({
            "messages": [
                {"role": "system", "content": "You are a helper"},
                {"role": "user", "content": "Hi"},
            ],
        }, "openai")
        assert len(blocks) == 1
        assert blocks[0]["hash"] == _sha256("You are a helper")

    def test_google_system_instruction(self):
        g = make_client()
        blocks = g._extract_blocks({
            "systemInstruction": {"parts": [{"text": "Gemini instructions"}]},
        }, "google")
        assert len(blocks) == 1
        assert blocks[0]["hash"] == _sha256("Gemini instructions")

    def test_no_system_returns_empty(self):
        g = make_client()
        assert g._extract_blocks({"model": "claude-sonnet-4-6"}, "anthropic") == []

    def test_format_based_not_provider_based(self):
        """system key works regardless of provider string."""
        g = make_client()
        blocks = g._extract_blocks({"system": [{"type": "text", "text": "Hello"}]}, "verygoodai")
        assert len(blocks) == 1
        assert blocks[0]["hash"] == _sha256("Hello")

    def test_token_estimation(self):
        """Tokens estimated as len(text) // 4. Approximate, not for billing."""
        g = make_client()
        text = "A" * 400  # 400 chars → 100 tokens
        blocks = g._extract_blocks({"system": text}, "anthropic")
        assert blocks[0]["tokens"] == 100

    def test_minimum_one_token(self):
        g = make_client()
        blocks = g._extract_blocks({"system": "Hi"}, "anthropic")
        assert blocks[0]["tokens"] >= 1


# ── Fingerprint ───────────────────────────────────────────────────────────────

class TestFingerprint:
    def test_none_for_empty(self):
        g = make_client()
        assert g._compute_fingerprint([]) is None

    def test_deterministic(self):
        g = make_client()
        blocks = [
            {"hash": "aaa", "tokens": 10, "cached": False, "position": 0},
            {"hash": "bbb", "tokens": 20, "cached": False, "position": 1},
        ]
        assert g._compute_fingerprint(blocks) == g._compute_fingerprint(blocks)

    def test_order_independent(self):
        """Blocks sorted by position before hashing, so input order doesn't matter."""
        g = make_client()
        b1 = {"hash": "aaa", "tokens": 10, "cached": False, "position": 0}
        b2 = {"hash": "bbb", "tokens": 20, "cached": False, "position": 1}
        assert g._compute_fingerprint([b1, b2]) == g._compute_fingerprint([b2, b1])

    def test_different_blocks_different_fingerprint(self):
        g = make_client()
        fp1 = g._compute_fingerprint([{"hash": "aaa", "tokens": 10, "cached": False, "position": 0}])
        fp2 = g._compute_fingerprint([{"hash": "bbb", "tokens": 10, "cached": False, "position": 0}])
        assert fp1 != fp2


# ── Usage extraction (format-based, not provider-based) ──────────────────────

class TestUsageExtraction:
    def test_anthropic_format(self):
        g = make_client()
        usage = g._extract_usage({
            "usage": {"input_tokens": 100, "output_tokens": 50,
                      "cache_creation_input_tokens": 200, "cache_read_input_tokens": 300}
        }, "anything")
        assert usage == {"input": 100, "output": 50, "cache_write": 200, "cache_read": 300}

    def test_openai_format(self):
        g = make_client()
        usage = g._extract_usage({
            "usage": {"prompt_tokens": 500, "completion_tokens": 100,
                      "prompt_tokens_details": {"cached_tokens": 200}}
        }, "anything")
        assert usage == {"input": 500, "output": 100, "cache_write": 0, "cache_read": 200}

    def test_google_format(self):
        g = make_client()
        usage = g._extract_usage({
            "usageMetadata": {"promptTokenCount": 400, "candidatesTokenCount": 80,
                              "cachedContentTokenCount": 150}
        }, "anything")
        assert usage == {"input": 400, "output": 80, "cache_write": 0, "cache_read": 150}

    def test_non_convertible_returns_zeros(self):
        """Strings and other non-convertible types return zero usage."""
        g = make_client()
        assert g._extract_usage("not a dict", "x") == {"input": 0, "output": 0, "cache_write": 0, "cache_read": 0}
        assert g._extract_usage(42, "x") == {"input": 0, "output": 0, "cache_write": 0, "cache_read": 0}
        assert g._extract_usage(None, "x") == {"input": 0, "output": 0, "cache_write": 0, "cache_read": 0}

    def test_pydantic_model_dump(self):
        """SDK response objects with model_dump() are converted to dicts."""
        g = make_client()

        class FakeResponse:
            def model_dump(self):
                return {"usage": {"input_tokens": 100, "output_tokens": 50,
                                  "cache_creation_input_tokens": 10, "cache_read_input_tokens": 20}}

        usage = g._extract_usage(FakeResponse(), "anthropic")
        assert usage == {"input": 100, "output": 50, "cache_write": 10, "cache_read": 20}

    def test_to_dict_fallback(self):
        """SDK response objects with to_dict() are converted to dicts."""
        g = make_client()

        class FakeResponse:
            def to_dict(self):
                return {"usage": {"prompt_tokens": 200, "completion_tokens": 80,
                                  "prompt_tokens_details": {"cached_tokens": 50}}}

        usage = g._extract_usage(FakeResponse(), "openai")
        assert usage == {"input": 200, "output": 80, "cache_write": 0, "cache_read": 50}

    def test_dunder_dict_fallback(self):
        """Objects with __dict__ are converted as last resort."""
        g = make_client()

        class FakeResponse:
            def __init__(self):
                self.usage = {"input_tokens": 30, "output_tokens": 10}

        usage = g._extract_usage(FakeResponse(), "anthropic")
        assert usage["input"] == 30
        assert usage["output"] == 10

    def test_empty_usage_returns_zeros(self):
        g = make_client()
        assert g._extract_usage({}, "x") == {"input": 0, "output": 0, "cache_write": 0, "cache_read": 0}


# ── Cache application ────────────────────────────────────────────────────────

class TestApplyCache:
    def _manifest(self, *texts):
        entries = [{"hash": _sha256(t), "tokens": 100, "priority": 1.0, "position": i}
                   for i, t in enumerate(texts)]
        return {
            "manifest_version": "v1",
            "cache_train": entries,
            "provider_hints": {"anthropic": {"breakpoint_positions": list(range(len(texts))), "cache_type": "ephemeral"}},
        }

    def test_marks_matching_block(self):
        g = make_client()
        text = "System prompt"
        result = g._apply_anthropic_cache(
            {"system": [{"type": "text", "text": text}]},
            self._manifest(text),
        )
        assert result["system"][0]["cache_control"] == {"type": "ephemeral"}

    def test_does_not_mark_non_matching(self):
        g = make_client()
        result = g._apply_anthropic_cache(
            {"system": [{"type": "text", "text": "different"}]},
            self._manifest("other"),
        )
        assert "cache_control" not in result["system"][0]

    def test_string_system_becomes_array(self):
        g = make_client()
        text = "Simple system"
        result = g._apply_anthropic_cache({"system": text}, self._manifest(text))
        assert isinstance(result["system"], list)
        assert result["system"][0]["cache_control"] == {"type": "ephemeral"}

    def test_min_spacing_prevents_early_breakpoint(self):
        """Only blocks at breakpoint_positions receive cache_control; others are skipped."""
        g = make_client()
        t1, t2, t3 = "A" * 400, "B" * 400, "C" * 400
        manifest = {
            "manifest_version": "v1",
            "cache_train": [
                {"hash": _sha256(t1), "tokens": 100, "priority": 1.0, "position": 0},
                {"hash": _sha256(t2), "tokens": 100, "priority": 1.0, "position": 1},
                {"hash": _sha256(t3), "tokens": 100, "priority": 1.0, "position": 2},
            ],
            "provider_hints": {"anthropic": {"breakpoint_positions": [2], "cache_type": "ephemeral"}},
        }
        result = g._apply_anthropic_cache(
            {"system": [{"type": "text", "text": t1}, {"type": "text", "text": t2}, {"type": "text", "text": t3}]},
            manifest,
        )
        # Positions 0 and 1 are NOT in breakpoint_positions → no cache_control
        assert "cache_control" not in result["system"][0]
        assert "cache_control" not in result["system"][1]
        # Position 2 IS in breakpoint_positions → cache_control injected
        assert result["system"][2].get("cache_control") == {"type": "ephemeral"}

    def test_preserves_other_params(self):
        g = make_client()
        text = "System"
        result = g._apply_anthropic_cache(
            {"system": text, "model": "claude-sonnet-4-6", "messages": [{"role": "user", "content": "Hi"}]},
            self._manifest(text),
        )
        assert result["model"] == "claude-sonnet-4-6"
        assert result["messages"] == [{"role": "user", "content": "Hi"}]


# ── Cold start / stale manifest ──────────────────────────────────────────────

class TestColdStart:
    def test_first_call_sends_naked(self):
        """First call ever should NOT apply cache breakpoints (no manifest yet)."""
        g = make_client(manifest_refresh_interval=300)
        text = "A" * 500
        params = {"model": "claude-sonnet-4-6", "system": text, "messages": []}

        received = {}
        def fn(p):
            received.update(p)
            return {"usage": {"input_tokens": 100, "output_tokens": 50}}

        g.call(params, fn)

        # System should be passed through unchanged (no cache_control added)
        if isinstance(received.get("system"), list):
            for block in received["system"]:
                assert "cache_control" not in block
        else:
            # String system — no cache_control possible
            assert isinstance(received.get("system"), str)

    def test_stale_manifest_still_applied(self):
        """After refresh interval expires, manifest should still be applied.

        A stale manifest is better than no optimization. The SDK triggers a
        background refresh but keeps using what it has.
        """
        g = make_client(manifest_refresh_interval=300)
        key = "anthropic/claude-sonnet-4-6"

        # Simulate: manifest was fetched 600 seconds ago (stale)
        g._last_manifest_fetch[key] = time.time() - 600
        g._manifest_data[key] = {
            "manifest_version": "v1",
            "cache_train": [{"hash": _sha256("A" * 500), "tokens": 500, "priority": 1, "position": 0}],
            "provider_hints": {"anthropic": {"breakpoint_positions": [0], "cache_type": "ephemeral"}},
        }

        received = {}
        def fn(p):
            received.update(p)
            return {"usage": {"input_tokens": 100, "output_tokens": 50}}

        g.call(
            {"model": "claude-sonnet-4-6", "system": "A" * 500, "messages": []},
            fn,
        )

        # Should STILL have cache_control — stale manifest is still applied
        system = received.get("system")
        if isinstance(system, list):
            has_cache = any("cache_control" in block for block in system)
            assert has_cache, "Stale manifest should still apply cache breakpoints"

    def test_fresh_manifest_applies_breakpoints(self):
        """With a fresh manifest, cache breakpoints should be applied."""
        g = make_client(manifest_refresh_interval=300)
        text = "A" * 500
        key = "anthropic/claude-sonnet-4-6"

        # Simulate: manifest was fetched 10 seconds ago (fresh)
        g._last_manifest_fetch[key] = time.time() - 10
        g._manifest_data[key] = {
            "manifest_version": "v1",
            "cache_train": [{"hash": _sha256(text), "tokens": 500, "priority": 1, "position": 0}],
            "provider_hints": {"anthropic": {"breakpoint_positions": [0], "cache_type": "ephemeral"}},
        }

        received = {}
        def fn(p):
            received.update(p)
            return {"usage": {"input_tokens": 100, "output_tokens": 50}}

        g.call(
            {"model": "claude-sonnet-4-6", "system": text, "messages": []},
            fn,
        )

        # Should have cache_control applied
        system = received.get("system")
        assert isinstance(system, list)
        has_cache_control = any(
            block.get("cache_control") for block in system if isinstance(block, dict)
        )
        assert has_cache_control

    def test_disabled_refresh_always_applies_manifest(self):
        """With manifest_refresh_interval=0, always apply the manifest (no staleness check)."""
        g = make_client(manifest_refresh_interval=0)
        text = "A" * 500
        key = "anthropic/claude-sonnet-4-6"

        g._manifest_data[key] = {
            "manifest_version": "v1",
            "cache_train": [{"hash": _sha256(text), "tokens": 500, "priority": 1, "position": 0}],
            "provider_hints": {"anthropic": {"breakpoint_positions": [0], "cache_type": "ephemeral"}},
        }

        received = {}
        def fn(p):
            received.update(p)
            return {"usage": {"input_tokens": 100, "output_tokens": 50}}

        g.call(
            {"model": "claude-sonnet-4-6", "system": text, "messages": []},
            fn,
        )

        system = received.get("system")
        assert isinstance(system, list)
        has_cache_control = any(
            block.get("cache_control") for block in system if isinstance(block, dict)
        )
        assert has_cache_control


    def test_multiple_calls_after_gap_all_optimized(self):
        """After a gap, ALL subsequent requests should use the manifest, not just the first."""
        g = make_client(manifest_refresh_interval=300)
        text = "A" * 500
        key = "anthropic/claude-sonnet-4-6"

        g._last_manifest_fetch[key] = time.time() - 600  # stale
        g._manifest_data[key] = {
            "manifest_version": "v1",
            "cache_train": [{"hash": _sha256(text), "tokens": 500, "priority": 1, "position": 0}],
            "provider_hints": {"anthropic": {"breakpoint_positions": [0], "cache_type": "ephemeral"}},
        }

        for i in range(3):
            received = {}
            def fn(p, _r=received):
                _r.update(p)
                return {"usage": {"input_tokens": 100, "output_tokens": 50}}
            g.call({"model": "claude-sonnet-4-6", "system": text, "messages": []}, fn)
            system = received.get("system")
            if isinstance(system, list):
                has_cache = any("cache_control" in block for block in system)
                assert has_cache, f"Request {i+1} after gap should still apply manifest"

    def test_manifest_fetch_failure_keeps_old_manifest(self):
        """If manifest refresh fails, the old manifest should still be available."""
        g = make_client(manifest_refresh_interval=300)
        key = "anthropic/claude-sonnet-4-6"

        g._manifest_data[key] = {
            "manifest_version": "v1",
            "cache_train": [{"hash": "abc", "tokens": 100, "priority": 1, "position": 0}],
        }

        # Simulate a failed fetch
        http = MagicMock()
        http.get.side_effect = Exception("connection refused")
        g._http = http
        g.manifest._http = http

        g._get_manifest("anthropic", "claude-sonnet-4-6")

        # Old manifest should still be there
        assert g._manifest_data[key]["manifest_version"] == "v1"

    def test_empty_cache_train_passes_through(self):
        """Manifest with empty cache_train should pass through params unchanged."""
        g = make_client(manifest_refresh_interval=0)
        key = "anthropic/claude-sonnet-4-6"

        g._manifest_data[key] = {
            "manifest_version": "v1",
            "cache_train": [],
        }

        received = {}
        def fn(p):
            received.update(p)
            return {"usage": {"input_tokens": 100, "output_tokens": 50}}

        original_system = "My system prompt"
        g.call({"model": "claude-sonnet-4-6", "system": original_system, "messages": []}, fn)

        # Empty cache_train = no breakpoints to apply = pass through
        assert received.get("system") == original_system

    def test_stale_manifest_telemetry_has_version(self):
        """Telemetry from stale manifest should include the manifest_version so server can compare."""
        g = make_client(manifest_refresh_interval=300)
        key = "anthropic/claude-sonnet-4-6"

        g._last_manifest_fetch[key] = time.time() - 600
        g._manifest_data[key] = {
            "manifest_version": "v_old",
            "manifest_token": "tok_old",
            "cache_train": [{"hash": _sha256("A" * 500), "tokens": 500, "priority": 1, "position": 0}],
            "provider_hints": {"anthropic": {"min_breakpoint_spacing": 0, "cache_type": "ephemeral"}},
        }

        g.call(
            {"model": "claude-sonnet-4-6", "system": "A" * 500, "messages": []},
            lambda p: {"usage": {"input_tokens": 100, "output_tokens": 50}},
        )

        events = capture_buffer(g)
        telemetry = [e for e in events if e["type"] == "telemetry"]
        assert len(telemetry) >= 1
        assert telemetry[0]["payload"]["manifest_version"] == "v_old"

    def test_separate_manifests_per_provider_model(self):
        """Different provider/model combos should have independent manifests."""
        g = make_client(manifest_refresh_interval=0)
        text = "Shared text"

        g._manifest_data["anthropic/claude-sonnet-4-6"] = {
            "manifest_version": "v_anthropic",
            "cache_train": [{"hash": _sha256(text), "tokens": 100, "priority": 1, "position": 0}],
        }
        g._manifest_data["openai/gpt-4o"] = {
            "manifest_version": "v_openai",
            "cache_train": [{"hash": "different", "tokens": 100, "priority": 1, "position": 0}],
        }

        # Anthropic call
        g.call(
            {"model": "claude-sonnet-4-6", "system": text, "messages": []},
            lambda p: {"usage": {"input_tokens": 10, "output_tokens": 5}},
        )
        # OpenAI call
        g.call(
            {"model": "gpt-4o", "messages": [{"role": "system", "content": text}, {"role": "user", "content": "Hi"}]},
            lambda p: {"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
        )

        events = capture_buffer(g)
        telemetry = [e for e in events if e["type"] == "telemetry"]
        versions = {e["payload"]["manifest_version"] for e in telemetry}
        assert "v_anthropic" in versions
        assert "v_openai" in versions

    def test_openai_stale_manifest_passes_through(self):
        """OpenAI doesn't use cache breakpoints — stale manifest should still pass through cleanly."""
        g = make_client(manifest_refresh_interval=300)
        key = "openai/gpt-4o"

        g._last_manifest_fetch[key] = time.time() - 600
        g._manifest_data[key] = {
            "manifest_version": "v1",
            "cache_train": [{"hash": "abc", "tokens": 100, "priority": 1, "position": 0}],
        }

        received = {}
        def fn(p):
            received.update(p)
            return {"usage": {"prompt_tokens": 100, "completion_tokens": 50}}

        g.call(
            {"model": "gpt-4o", "messages": [{"role": "system", "content": "Hi"}, {"role": "user", "content": "Hello"}]},
            fn,
        )

        # OpenAI: no cache_control markers — caching is automatic
        for msg in received.get("messages", []):
            assert "cache_control" not in msg


# ── Telemetry reporting ──────────────────────────────────────────────────────

class TestTelemetryReport:
    def test_no_cost_fields(self):
        """SDK must not send cost, savings, memoized, or memo_savings."""
        g = make_client()

        g._queue_telemetry(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            {"usage": {"input_tokens": 100, "output_tokens": 50}},
        )

        events = capture_buffer(g)
        event = events[0]["payload"]
        # Server-only fields should NOT be in SDK telemetry
        for field in ("cost", "cost_without_optimization", "savings", "memo_savings"):
            assert field not in event, f"SDK should not send '{field}'"
        # memoized flag IS sent by SDK (explicit, not inferred by server)
        assert event["memoized"] is False

    def test_includes_usage(self):
        g = make_client()

        g._queue_telemetry(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            {"usage": {"input_tokens": 100, "output_tokens": 50,
                       "cache_creation_input_tokens": 200, "cache_read_input_tokens": 300}},
        )

        events = capture_buffer(g)
        usage = events[0]["payload"]["usage"]
        assert usage == {"input_tokens": 100, "output_tokens": 50,
                         "cache_write_tokens": 200, "cache_read_tokens": 300}

    def test_includes_blocks_and_fingerprint(self):
        g = make_client()

        g._queue_telemetry(
            {"model": "claude-sonnet-4-6", "system": [
                {"type": "text", "text": "A"},
                {"type": "text", "text": "B"},
            ], "messages": []},
            {"usage": {"input_tokens": 10, "output_tokens": 5}},
        )

        events = capture_buffer(g)
        event = events[0]["payload"]
        assert len(event["blocks"]) == 2
        assert event["blocks"][0]["hash"] == _sha256("A")
        assert event["blocks"][1]["hash"] == _sha256("B")
        assert event["fingerprint"] is not None

    def test_includes_manifest_version_and_token(self):
        g = make_client()
        g._manifest_data["anthropic/claude-sonnet-4-6"] = {
            "manifest_version": "v42",
            "manifest_token": "tok_abc",
            "cache_train": [],
        }

        g._queue_telemetry(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            {"usage": {"input_tokens": 10, "output_tokens": 5}},
        )

        events = capture_buffer(g)
        event = events[0]["payload"]
        assert event["manifest_version"] == "v42"
        assert event["manifest_token"] == "tok_abc"

    def test_includes_required_fields(self):
        g = make_client()

        g._queue_telemetry(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            {"usage": {"input_tokens": 10, "output_tokens": 5}},
        )

        events = capture_buffer(g)
        event = events[0]["payload"]
        assert "event_id" in event
        assert "timestamp" in event
        assert "model" in event
        assert "provider" in event
        assert "blocks" in event
        assert "usage" in event
        assert "latency_ms" in event


class TestMemoizedReport:
    def test_zero_usage(self):
        g = make_client()

        blocks = [{"hash": "abc", "tokens": 100, "cached": True, "position": 0}]
        g._queue_memoized_telemetry(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            blocks, "fp_xyz", 50,
        )

        events = capture_buffer(g)
        event = events[0]["payload"]
        assert event["usage"] == {"input_tokens": 0, "output_tokens": 0,
                                   "cache_write_tokens": 0, "cache_read_tokens": 0}
        assert event["memoized"] is True

    def test_no_cost_fields(self):
        g = make_client()

        g._queue_memoized_telemetry(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            [], "fp_xyz", 50,
        )

        events = capture_buffer(g)
        event = events[0]["payload"]
        for field in ("cost", "memo_savings"):
            assert field not in event
        assert event["memoized"] is True

    def test_includes_fingerprint(self):
        g = make_client()

        g._queue_memoized_telemetry(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            [], "fp_xyz", 50,
        )

        events = capture_buffer(g)
        assert events[0]["payload"]["fingerprint"] == "fp_xyz"


# ── Manifest ack ──────────────────────────────────────────────────────────────

class TestManifestAck:
    def test_ack_on_receive(self):
        g = make_client()
        http = mock_http(
            {"manifest_version": "v1", "manifest_token": "tok_test", "cache_train": []},
            headers={"etag": '"v1"'},
        )
        g._http = http
        g.manifest._http = http

        g._get_manifest("anthropic", "claude-sonnet-4-6")

        events = capture_buffer(g)
        ack_events = [e for e in events if e["type"] == "manifest_ack"]
        assert len(ack_events) == 1
        assert ack_events[0]["payload"]["manifest_version"] == "v1"
        assert ack_events[0]["payload"]["manifest_token"] == "tok_test"

    def test_no_ack_without_token(self):
        g = make_client()
        http = mock_http({"manifest_version": "v1", "cache_train": []}, headers={"etag": '"v1"'})
        g._http = http
        g.manifest._http = http

        g._get_manifest("anthropic", "claude-sonnet-4-6")

        events = capture_buffer(g)
        ack_events = [e for e in events if e["type"] == "manifest_ack"]
        assert len(ack_events) == 0

    def test_ack_failure_does_not_block(self):
        g = make_client()
        http = MagicMock()
        http.get.return_value = make_http_response(
            {"manifest_version": "v1", "manifest_token": "tok", "cache_train": []},
            headers={"etag": '"v1"'},
        )
        g._http = http
        g.manifest._http = http

        # Should not raise — buffer.put() is used instead of HTTP, so no HTTP error here
        result = g._get_manifest("anthropic", "claude-sonnet-4-6")
        assert result is not None


# ── call() safety guarantee ──────────────────────────────────────────────────

class TestCallSafety:
    def test_calls_fn_with_original_on_error(self):
        g = make_client()
        original = {"model": "claude-sonnet-4-6", "system": "hello", "messages": []}
        received = {}

        def fn(p):
            received.update(p)
            return {"usage": {}}

        result = g.call(original, fn)
        assert result.memoized is False
        # On error (no manifest fetch configured, but errors caught), fn still gets called

    def test_returns_fn_response(self):
        g = make_client()
        expected = {"id": "msg_123", "content": [], "usage": {}}
        result = g.call({"model": "claude-sonnet-4-6", "messages": []}, lambda p: expected)
        assert result.response is expected
        assert result.memoized is False

    def test_unknown_provider_falls_back(self):
        """Unknown provider should not crash — fall back to original params."""
        g = make_client()
        original = {"model": "unknown-xyz", "messages": [{"role": "user", "content": "Hi"}]}
        fn_params = {}

        def fn(p):
            fn_params.update(p)
            return {"usage": {}}

        result = g.call(original, fn)
        assert result.memoized is False
        assert fn_params.get("model") == "unknown-xyz"

    def test_unknown_provider_reports_error(self):
        """Unknown provider should fire-and-forget an error report."""
        g = make_client()

        g.call({"model": "unknown-xyz", "messages": []}, lambda p: {"usage": {}})

        events = capture_buffer(g)
        error_events = [e for e in events if e["type"] == "error_report"]
        assert len(error_events) >= 1
        assert "Unrecognized" in error_events[0]["payload"]["message"]


# ── Unrecognized provider / model / format edge cases ────────────────────────
#
# Core safety guarantee: Genosis must NEVER crash the caller's LLM call
# regardless of what model name, provider, or request shape it receives.
# Unknown inputs fall back to calling fn with the original params.

class TestUnrecognizedProviderModelFormat:

    def test_empty_string_model_falls_back(self):
        """Empty string model → fn still called with original params."""
        g = make_client()
        params = {"model": "", "messages": [{"role": "user", "content": "hi"}]}
        fn_received = {}

        def fn(p):
            fn_received.update(p)
            return {"usage": {}}

        result = g.call(params, fn)
        assert result.response is not None
        assert fn_received.get("model") == ""

    def test_none_model_falls_back(self):
        """None model value → fn still called with original params."""
        g = make_client()
        params = {"model": None, "messages": [{"role": "user", "content": "hi"}]}
        called_with = [None]

        def fn(p):
            called_with[0] = p
            return {"usage": {}}

        result = g.call(params, fn)
        assert result.response is not None
        assert called_with[0] is not None

    def test_no_model_key_falls_back(self):
        """Missing model key entirely → fn still called with original params."""
        g = make_client()
        params = {"messages": [{"role": "user", "content": "hi"}]}
        fn_received = [None]

        def fn(p):
            fn_received[0] = p
            return {"choices": []}

        result = g.call(params, fn)
        assert result.response is not None
        assert fn_received[0] == params  # passed through unchanged

    def test_empty_params_falls_back(self):
        """Completely empty params → falls back, fn still called."""
        g = make_client()
        fn_received = [None]

        def fn(p):
            fn_received[0] = p
            return {}

        result = g.call({}, fn)
        assert result.response is not None
        assert fn_received[0] == {}

    def test_params_no_messages_no_system_falls_back(self):
        """No messages, no system, no systemInstruction → provider cannot be inferred, falls back."""
        g = make_client()
        params = {"model": "some-future-model-v99", "extra_field": "value"}
        fn_received = [None]

        def fn(p):
            fn_received[0] = p
            return {"choices": []}

        result = g.call(params, fn)
        assert result.response is not None
        assert fn_received[0] == params

    def test_provider_map_invalid_provider_value_falls_back(self):
        """provider_map pointing to an unsupported provider → graceful fallback."""
        g = make_client(provider_map={"my-custom-model": "unsupported-provider-xyz"})
        fn_call_count = [0]

        def fn(p):
            fn_call_count[0] += 1
            return {"choices": []}

        result = g.call({"model": "my-custom-model", "messages": []}, fn)
        assert result.response is not None
        assert fn_call_count[0] == 1  # called exactly once

    def test_unrecognized_model_queues_exactly_one_error_report(self):
        """Unrecognized model queues exactly one error_report (no duplicate warnings)."""
        g = make_client()

        g.call({"model": "unknown-llm-7b", "messages": []}, lambda p: {"usage": {}})

        events = capture_buffer(g)
        error_events = [e for e in events if e["type"] == "error_report"]
        assert len(error_events) == 1
        assert "nrecognized" in error_events[0]["payload"]["message"]

    def test_fn_error_propagates_for_unknown_provider(self):
        """LLM errors are never swallowed, even when provider is unrecognized."""
        g = make_client()

        def fn(p):
            raise RuntimeError("Rate limit exceeded")

        with pytest.raises(RuntimeError, match="Rate limit exceeded"):
            g.call({"model": "unknown-xyz", "messages": []}, fn)

    def test_fn_error_propagates_for_known_provider(self):
        """LLM errors are never swallowed for known providers either."""
        g = make_client()

        def fn(p):
            raise RuntimeError("Context window exceeded")

        with pytest.raises(RuntimeError, match="Context window exceeded"):
            g.call({"model": "claude-sonnet-4-6", "messages": []}, fn)

    def test_model_with_special_characters_falls_back(self):
        """Model names with spaces, slashes, or unicode → fall back gracefully."""
        g = make_client()

        unusual_models = ["gpt 4o", "claude/opus", "gemini-2.5™", "my model (v2)"]
        for model in unusual_models:
            fn_called = [False]

            def fn(p, _m=model):
                fn_called[0] = True
                return {"choices": []}

            result = g.call({"model": model, "messages": []}, fn)
            assert result.response is not None, f"call() crashed for model={model!r}"
            assert fn_called[0], f"fn not called for model={model!r}"


# ── Cloud Provider Support ───────────────────────────────────────────────────

class TestCloudProviderDetection:
    """AWS Bedrock, Azure OpenAI, and Vertex AI should map to canonical providers."""

    def test_bedrock_anthropic_detection(self):
        g = make_client()
        assert g._detect_provider({"model": "anthropic.claude-sonnet-4-6-20250514-v1:0"}) == "anthropic"

    def test_bedrock_anthropic_with_region(self):
        g = make_client()
        assert g._detect_provider({"model": "us.anthropic.claude-3-5-haiku-20241022-v1:0"}) == "anthropic"

    def test_bedrock_anthropic_simple(self):
        g = make_client()
        assert g._detect_provider({"model": "anthropic.claude-3-5-sonnet-20241022-v2:0"}) == "anthropic"

    def test_azure_openai_by_shape(self):
        """Azure uses deployment names, but request shape is OpenAI format."""
        g = make_client()
        # Azure deployment names are arbitrary but request shape has messages array
        # with role: system. Detect as OpenAI is not guaranteed here since deployment
        # names are user-defined. The user's fn handles Azure auth/routing.
        # If model starts with gpt/o1/o3/o4, detection works.
        assert g._detect_provider({"model": "gpt-4o", "messages": []}) == "openai"

    def test_vertex_ai_detection(self):
        """Vertex AI uses the same model names as direct Google API."""
        g = make_client()
        assert g._detect_provider({"model": "gemini-2.0-flash", "systemInstruction": {}}) == "google"

    def test_direct_providers_still_work(self):
        g = make_client()
        assert g._detect_provider({"model": "claude-sonnet-4-6"}) == "anthropic"
        assert g._detect_provider({"model": "gpt-4o"}) == "openai"
        assert g._detect_provider({"model": "gemini-1.5-pro"}) == "google"
        assert g._detect_provider({"model": "vgai-test"}) == "verygoodai"


class TestModelNormalization:
    """Bedrock model IDs should normalize to canonical names."""

    def test_bedrock_full_id(self):
        assert Genosis._normalize_model("anthropic.claude-sonnet-4-6-20250514-v1:0") == "claude-sonnet-4-6-20250514"

    def test_bedrock_with_region(self):
        assert Genosis._normalize_model("us.anthropic.claude-3-5-haiku-20241022-v1:0") == "claude-3-5-haiku-20241022"

    def test_bedrock_v2(self):
        assert Genosis._normalize_model("anthropic.claude-3-5-sonnet-20241022-v2:0") == "claude-3-5-sonnet-20241022"

    def test_direct_anthropic_unchanged(self):
        assert Genosis._normalize_model("claude-sonnet-4-6-20250514") == "claude-sonnet-4-6-20250514"

    def test_openai_unchanged(self):
        assert Genosis._normalize_model("gpt-4o") == "gpt-4o"

    def test_google_unchanged(self):
        assert Genosis._normalize_model("gemini-2.0-flash") == "gemini-2.0-flash"

    def test_empty_string(self):
        assert Genosis._normalize_model("") == ""


class TestCloudProviderCallFlow:
    """Bedrock model IDs should flow through call() with normalized telemetry."""

    def test_bedrock_call_normalizes_telemetry(self):
        g = make_client()
        bedrock_params = {
            "model": "anthropic.claude-sonnet-4-6-20250514-v1:0",
            "system": "You are helpful.",
            "messages": [{"role": "user", "content": "Hi"}],
        }
        response = {
            "usage": {
                "input_tokens": 100,
                "output_tokens": 50,
                "cache_creation_input_tokens": 0,
                "cache_read_input_tokens": 0,
            }
        }
        g.call(bedrock_params, lambda p: response)
        events = capture_buffer(g)
        telemetry = [e for e in events if e["type"] == "telemetry"]
        assert len(telemetry) >= 1
        assert telemetry[0]["payload"]["model"] == "claude-sonnet-4-6-20250514"
        assert telemetry[0]["payload"]["provider"] == "anthropic"


# ── Memoization ──────────────────────────────────────────────────────────────

class TestMemoization:
    def _setup_memo_client(self):
        g = make_client(memoization_enabled=True, memoization_max_entries=100)
        http, captured = capture_http()
        # Manifest GET raises so _get_manifest falls back to _manifest_data (our test data)
        from genosis.errors import NotFoundError
        def get_raises(path, **kwargs):
            if "/v1/manifest" in path:
                raise NotFoundError(404, "NO_MANIFEST", "not found")
            return make_http_response({})
        http.get = MagicMock(side_effect=get_raises)
        g._http = http
        g.manifest._http = http
        g.optimization._http = http
        g.telemetry._http = http
        g.account._http = http
        return g, captured

    def _make_manifest_with_candidate(self, fingerprint, max_response_tokens=2000):
        return {
            "manifest_version": "v1",
            "manifest_token": "tok",
            "cache_train": [],
            "memoization": {
                "enabled": True,
                "max_ttl_seconds": 3600,
                "candidates": [{
                    "fingerprint": fingerprint,
                    "ttl_seconds": 60,
                    "block_hashes": [],
                    "estimated_savings_per_hit": 0.01,
                    "max_response_tokens": max_response_tokens,
                }],
            },
        }

    def test_memo_hit(self):
        g, captured = self._setup_memo_client()
        text = "Repeating prompt"
        fp = g._compute_fingerprint([{"hash": _sha256(text), "tokens": 10, "cached": False, "position": 0}])
        cached_resp = {"id": "cached", "usage": {}}
        g._memo_storage.set(fp, cached_resp, 3600)
        g._manifest_data["anthropic/claude-sonnet-4-6"] = self._make_manifest_with_candidate(fp)

        call_count = 0
        def fn(p):
            nonlocal call_count
            call_count += 1
            return {"id": "fresh"}

        result = g.call(
            {"model": "claude-sonnet-4-6", "system": text, "messages": []},
            fn,
        )
        assert result.memoized is True
        assert result.response is cached_resp
        assert call_count == 0

    def test_memo_stores_after_first_call(self):
        """After call() with cacheable=True and a matching candidate, the response
        is stored in local memo storage. A second identical call returns memoized=True."""
        g = make_client(memoization_enabled=True, memoization_max_entries=100)

        text = "New prompt"
        blocks = g._extract_blocks({"system": text}, "anthropic")
        fp = g._compute_fingerprint(blocks)
        assert fp is not None

        g._manifest_data["anthropic/claude-sonnet-4-6"] = self._make_manifest_with_candidate(fp)

        http = MagicMock()
        http.post.return_value = make_http_response({"accepted": 1})
        http.get.return_value = make_http_response({}, status=304)
        g._http = http
        g.manifest._http = http

        response = {"id": "fresh", "usage": {"input_tokens": 10, "output_tokens": 5}}
        call_count = 0
        def fn(p):
            nonlocal call_count
            call_count += 1
            return response

        # First call — calls fn, stores memo
        r1 = g.call({"model": "claude-sonnet-4-6", "system": text, "messages": []},
                     fn)
        assert r1.memoized is False
        assert call_count == 1
        assert g._memo_storage.get(fp) is response

        # Second call — memo hit, fn NOT called
        r2 = g.call({"model": "claude-sonnet-4-6", "system": text, "messages": []},
                     fn)
        assert r2.memoized is True
        assert call_count == 1
        assert r2.response is response

    def test_memo_respects_size_cap(self):
        g, captured = self._setup_memo_client()
        text = "Prompt"
        fp = g._compute_fingerprint([{"hash": _sha256(text), "tokens": 10, "cached": False, "position": 0}])
        # Set very low size cap
        g._manifest_data["anthropic/claude-sonnet-4-6"] = self._make_manifest_with_candidate(fp, max_response_tokens=1)

        big_response = {"id": "x" * 1000, "usage": {"input_tokens": 10, "output_tokens": 5}}
        g.call({"model": "claude-sonnet-4-6", "system": text, "messages": []},
               lambda p: big_response)

        assert g._memo_storage.get(fp) is None  # Not stored — too large

    def test_memo_suspended_during_regime_shift(self):
        g, captured = self._setup_memo_client()
        g._manifest_data["anthropic/claude-sonnet-4-6"] = {
            "manifest_version": "v1",
            "cache_train": [],
            "mode": {"mode": "regime_shift", "suspend_memoization": True},
        }

        fn_called = []
        def fn(p):
            fn_called.append(True)
            return {"usage": {}}

        result = g.call(
            {"model": "claude-sonnet-4-6", "system": "hello", "messages": []},
            fn,
        )
        assert result.memoized is False
        assert fn_called

    def test_memo_disabled_when_memoization_not_enabled(self):
        g = make_client(memoization_enabled=False)
        text = "Prompt"
        fp = g._compute_fingerprint([{"hash": _sha256(text), "tokens": 10, "cached": False, "position": 0}])
        # Even if we manually set something in storage, memo is off at the client level
        assert g._memo_storage is None

        result = g.call(
            {"model": "claude-sonnet-4-6", "system": text, "messages": []},
            lambda p: {"id": "fresh", "usage": {}},
        )
        assert result.memoized is False
        assert result.response["id"] == "fresh"


# ── Error reporting ──────────────────────────────────────────────────────────

class TestErrorReporting:
    def test_report_error_fires_and_forgets(self):
        g = make_client()

        g._queue_error("test context", "something broke", "claude-sonnet-4-6")

        events = capture_buffer(g)
        error_events = [e for e in events if e["type"] == "error_report"]
        assert len(error_events) == 1
        payload = error_events[0]["payload"]
        assert payload["context"] == "test context"
        assert payload["message"] == "something broke"
        assert payload["model"] == "claude-sonnet-4-6"
        assert "timestamp" in payload

    def test_report_error_never_raises(self):
        g = make_client()

        # Should not raise — errors go to buffer, not HTTP
        g._queue_error("test", "error", "model")

    def test_manifest_fetch_failure_reports_error(self):
        g = make_client()
        http = MagicMock()
        http.get.side_effect = Exception("connection refused")
        g._http = http
        g.manifest._http = http

        g._get_manifest("anthropic", "claude-sonnet-4-6")

        events = capture_buffer(g)
        error_events = [e for e in events if e["type"] == "error_report"]
        assert len(error_events) >= 1

    def test_error_sanitizes_api_key(self):
        g = make_client()

        g._queue_error("test", "Failed with key gns_live_abc123def456_secret", "model")

        events = capture_buffer(g)
        payload = events[0]["payload"]
        assert "gns_live_abc123def456_secret" not in payload["message"]
        assert "REDACTED" in payload["message"]

    def test_error_sanitizes_file_paths(self):
        g = make_client()

        g._queue_error("test", "Error at /Users/sam/Github/genosis/secrets.py", "model")

        events = capture_buffer(g)
        payload = events[0]["payload"]
        assert "/Users/sam" not in payload["message"]
        assert "PATH_REDACTED" in payload["message"]

    def test_error_sanitizes_bearer_token(self):
        g = make_client()

        g._queue_error("test", "Auth failed: Bearer eyJhbGciOiJ...", "model")

        events = capture_buffer(g)
        payload = events[0]["payload"]
        assert "eyJhbGciOiJ" not in payload["message"]
        assert "REDACTED" in payload["message"]

    def test_error_logs_locally(self, caplog):
        """Errors should be logged via Python logging for customer support."""
        import logging
        g = make_client()

        with caplog.at_level(logging.WARNING, logger="genosis"):
            g._queue_error("test context", "something broke", "claude-sonnet-4-6")

        assert len(caplog.records) >= 1
        assert "something broke" in caplog.records[0].message
        assert "test context" in caplog.records[0].message

    def test_call_fallback_logs_locally(self, caplog):
        """Unknown provider logs a warning — exactly one, from _optimize."""
        import logging
        g = make_client()
        http, captured = capture_http()
        g._http = http

        with caplog.at_level(logging.WARNING, logger="genosis"):
            g.call({"model": "unknown-xyz", "messages": []}, lambda p: {"usage": {}})

        warning_msgs = [r.message for r in caplog.records if r.levelno >= logging.WARNING]
        # Exactly one warning: the _optimize failed report. call() no longer emits a
        # second "falling back" warning — that was a duplicate of the same error.
        assert len(warning_msgs) == 1
        assert "nrecognized" in warning_msgs[0]


# ── Pluggable memo storage ───────────────────────────────────────────────────

class TestPluggableStorage:
    def test_custom_storage_used(self):
        stored = {}

        class DictStorage(MemoStorage):
            def get(self, fp):
                entry = stored.get(fp)
                if entry and time.time() < entry["expires"]:
                    return entry["response"]
                return None
            def set(self, fp, response, ttl):
                stored[fp] = {"response": response, "expires": time.time() + ttl}

        g = make_client(memoization_enabled=True, memo_storage=DictStorage())
        text = "Test prompt"
        fp = g._compute_fingerprint([{"hash": _sha256(text), "tokens": 10, "cached": False, "position": 0}])

        manifest = {
            "manifest_version": "v1", "manifest_token": "tok", "cache_train": [],
            "memoization": {"enabled": True, "max_ttl_seconds": 3600,
                            "candidates": [{"fingerprint": fp, "ttl_seconds": 60,
                                            "block_hashes": [], "estimated_savings_per_hit": 0.01,
                                            "max_response_tokens": 2000}]},
        }
        g._manifest_data["anthropic/claude-sonnet-4-6"] = manifest

        http, captured = capture_http()
        g._http = http

        # First call — stores in custom storage
        response = {"id": "resp1", "usage": {"input_tokens": 10, "output_tokens": 5}}
        g.call({"model": "claude-sonnet-4-6", "system": text, "messages": []},
               lambda p: response)
        assert fp in stored

        # Second call — retrieved from custom storage
        result = g.call(
            {"model": "claude-sonnet-4-6", "system": text, "messages": []},
            lambda p: {"id": "should_not_be_called"},
        )
        assert result.memoized is True
        assert result.response["id"] == "resp1"


# ── InMemoryMemoStorage ─────────────────────────────────────────────────────

class TestInMemoryMemoStorage:
    def test_get_set(self):
        cache = InMemoryMemoStorage(10)
        cache.set("fp1", {"data": 1}, 3600)
        assert cache.get("fp1") == {"data": 1}

    def test_ttl_expiry(self):
        cache = InMemoryMemoStorage(10)
        cache.set("fp1", {"data": 1}, 0)  # expires immediately
        time.sleep(0.01)
        assert cache.get("fp1") is None

    def test_lru_eviction(self):
        cache = InMemoryMemoStorage(2)
        cache.set("fp1", "a", 3600)
        cache.set("fp2", "b", 3600)
        cache.set("fp3", "c", 3600)  # evicts fp1
        assert cache.get("fp1") is None
        assert cache.get("fp2") == "b"
        assert cache.get("fp3") == "c"

    def test_len(self):
        cache = InMemoryMemoStorage(10)
        assert len(cache) == 0
        cache.set("fp1", "a", 3600)
        assert len(cache) == 1


# ── Sub-client methods ───────────────────────────────────────────────────────

class TestAccountClient:
    def test_get(self):
        g = make_client()
        expected = {"id": "cust_1", "name": "Test"}
        g._http = mock_http(expected)
        g.account._http = g._http
        assert g.account.get() == expected

    def test_get_usage(self):
        g = make_client()
        expected = {"total_requests": 42}
        g._http = mock_http(expected)
        g.account._http = g._http
        assert g.account.get_usage() == expected

    def test_list_api_keys(self):
        g = make_client()
        keys = [{"id": "k1"}]
        g._http = mock_http({"api_keys": keys})
        g.account._http = g._http
        assert g.account.list_api_keys() == keys

    def test_revoke_api_key(self):
        g = make_client()
        g._http = mock_http({"revoked": True})
        g.account._http = g._http
        assert g.account.revoke_api_key("k1") == {"revoked": True}


class TestManifestClient:
    def test_get(self):
        g = make_client()
        data = {"manifest_version": "v1", "cache_train": []}
        g._http = mock_http(data)
        g.manifest._http = g._http
        assert g.manifest.get("anthropic", "claude-sonnet-4-6")["data"] == data

    def test_etag_stored(self):
        g = make_client()
        g._http = mock_http({"manifest_version": "v1"}, headers={"etag": '"abc"'})
        g.manifest._http = g._http
        g.manifest.get("anthropic", "claude-sonnet-4-6")
        assert g.manifest._etag_map.get("anthropic/claude-sonnet-4-6") == '"abc"'

    def test_304_returns_none(self):
        g = make_client()
        g._http = mock_http({}, status=304)
        g.manifest._http = g._http
        assert g.manifest.get("anthropic", "claude-sonnet-4-6")["data"] is None

    def test_list_all(self):
        g = make_client()
        manifests = [{"provider": "anthropic"}]
        g._http = mock_http({"manifests": manifests})
        g.manifest._http = g._http
        assert g.manifest.list_all() == manifests

    def test_get_history(self):
        g = make_client()
        history = [{"id": "h1"}]
        g._http = mock_http({"manifests": history})
        g.manifest._http = g._http
        assert g.manifest.get_history(provider="anthropic") == history


class TestOptimizationClient:
    def test_trigger(self):
        g = make_client()
        g._http = mock_http({"status": "completed"})
        g.optimization._http = g._http
        assert g.optimization.trigger() == {"status": "completed"}

    def test_get_status(self):
        g = make_client()
        g._http = mock_http({"status": "running"})
        g.optimization._http = g._http
        assert g.optimization.get_status() == {"status": "running"}

    def test_get_results(self):
        g = make_client()
        g._http = mock_http({"id": "run1"})
        g.optimization._http = g._http
        assert g.optimization.get_results() == {"id": "run1"}


class TestTelemetryClient:
    def test_get_summary(self):
        g = make_client()
        g._http = mock_http({"total_requests": 100})
        g.telemetry._http = g._http
        assert g.telemetry.get_summary(days=7) == {"total_requests": 100}

    def test_get_cost_breakdown(self):
        g = make_client()
        breakdown = [{"date": "2026-03-01"}]
        g._http = mock_http({"cost_breakdown": breakdown})
        g.telemetry._http = g._http
        assert g.telemetry.get_cost_breakdown() == breakdown

    def test_get_block_frequencies(self):
        g = make_client()
        data = {"blocks": [{"hash": "abc"}]}
        g._http = mock_http(data)
        g.telemetry._http = g._http
        assert g.telemetry.get_block_frequencies() == data


# ── Empty manifest fast-retry ────────────────────────────────────────────────

class TestEmptyManifestFastRetry:
    """After receiving an empty manifest, the SDK should schedule the next fetch
    in ~60s (fast retry) instead of waiting the full refresh interval, up to
    FAST_RETRY_MAX consecutive times before falling back to normal cadence."""

    _EMPTY = {"manifest_version": "v1", "manifest_token": "tok", "cache_train": []}
    _NONEMPTY = {
        "manifest_version": "v1",
        "manifest_token": "tok",
        "cache_train": [{"hash": "abc", "tokens": 100, "priority": 1, "position": 0}],
    }

    def _mock_manifest(self, g, data):
        m = MagicMock()
        m.get.return_value = {"data": data, "etag": None}
        g.manifest = m

    def test_empty_increments_retry_counter(self):
        """Each empty manifest response increments _empty_manifest_retries for that key."""
        g = make_client(manifest_refresh_interval=300)
        key = "anthropic/claude-sonnet-4-6"
        self._mock_manifest(g, self._EMPTY)

        g._get_manifest("anthropic", "claude-sonnet-4-6")
        assert g._empty_manifest_retries.get(key, 0) == 1

        g._get_manifest("anthropic", "claude-sonnet-4-6")
        assert g._empty_manifest_retries.get(key, 0) == 2

    def test_empty_sets_early_timestamp_for_fast_retry(self):
        """An empty manifest should back-date _last_manifest_fetch so the next
        optimize() cycle wakes up in ~60s rather than the full refresh interval."""
        g = make_client(manifest_refresh_interval=300)
        key = "anthropic/claude-sonnet-4-6"
        self._mock_manifest(g, self._EMPTY)

        before = time.time()
        g._get_manifest("anthropic", "claude-sonnet-4-6")

        ts = g._last_manifest_fetch[key]
        # With refresh_interval=300 and fast_retry=60, the back-dated stamp is
        # approximately (now - 300 + 60) = (now - 240). It must be well in the past
        # (> 50s ago) so optimize() retries in ~60s, not ~300s.
        assert ts < before - 50, (
            f"Expected early timestamp but got ts={ts:.2f}, before={before:.2f} "
            f"(diff={before - ts:.2f}s)"
        )

    def test_fast_retry_caps_after_max_empties(self):
        """After FAST_RETRY_MAX consecutive empty responses the SDK reverts to
        the normal refresh interval (last_manifest_fetch set to ~now)."""
        from genosis.client import FAST_RETRY_MAX
        g = make_client(manifest_refresh_interval=300)
        key = "anthropic/claude-sonnet-4-6"
        self._mock_manifest(g, self._EMPTY)

        for _ in range(FAST_RETRY_MAX):
            g._get_manifest("anthropic", "claude-sonnet-4-6")

        # The (FAST_RETRY_MAX + 1)th call should set a normal (recent) timestamp
        before = time.time()
        g._get_manifest("anthropic", "claude-sonnet-4-6")
        after = time.time()

        ts = g._last_manifest_fetch[key]
        assert ts >= before - 0.5, (
            f"Expected recent timestamp after exceeding FAST_RETRY_MAX, "
            f"but got ts={ts:.2f}, before={before:.2f}"
        )

    def test_nonempty_manifest_resets_counter(self):
        """A non-empty manifest clears _empty_manifest_retries for that key."""
        g = make_client(manifest_refresh_interval=300)
        key = "anthropic/claude-sonnet-4-6"
        g._empty_manifest_retries[key] = 3  # pre-seed some accumulated retries

        self._mock_manifest(g, self._NONEMPTY)
        g._get_manifest("anthropic", "claude-sonnet-4-6")

        assert key not in g._empty_manifest_retries

    def test_nonempty_manifest_sets_normal_timestamp(self):
        """A non-empty manifest sets _last_manifest_fetch to approximately now."""
        g = make_client(manifest_refresh_interval=300)
        key = "anthropic/claude-sonnet-4-6"
        self._mock_manifest(g, self._NONEMPTY)

        before = time.time()
        g._get_manifest("anthropic", "claude-sonnet-4-6")
        after = time.time()

        ts = g._last_manifest_fetch[key]
        assert before - 0.5 <= ts <= after + 0.5

    def test_counters_are_independent_per_key(self):
        """Empty manifests for different provider/model combos accumulate separately."""
        g = make_client(manifest_refresh_interval=300)
        self._mock_manifest(g, self._EMPTY)

        g._get_manifest("anthropic", "claude-sonnet-4-6")
        g._get_manifest("anthropic", "claude-sonnet-4-6")
        g._get_manifest("openai", "gpt-4o")

        assert g._empty_manifest_retries.get("anthropic/claude-sonnet-4-6", 0) == 2
        assert g._empty_manifest_retries.get("openai/gpt-4o", 0) == 1
