from collections import defaultdict

import api.models as models


class _CountingList(list):
    def __init__(self, *args, str_counter):
        super().__init__(*args)
        self._str_counter = str_counter

    def __str__(self):
        self._str_counter["count"] += 1
        return super().__str__()


class _ExplodingList(list):
    def __init__(self, *args, str_counter):
        super().__init__(*args)
        self._str_counter = str_counter

    def __str__(self):
        self._str_counter["count"] += 1
        raise RuntimeError("content __str__ must not run")


def _count_content_normalizations(monkeypatch):
    """Count how often structured content is actually normalized.

    These tests originally counted ``__str__`` on the content list, because the
    key builders stringified content with ``str()``. Structured content is now
    serialized canonically (type-namespaced) instead, so ``str()`` never runs on
    a list and the old counter reads zero.

    The property under test is unchanged -- expensive normalization of the same
    content must happen a bounded number of times, not once per key -- so the
    counter now observes the normalization helper itself and ignores the cheap
    passthrough calls where content has already been reduced to a string.
    """
    counts = {"count": 0}
    original = models._canonical_structured_content

    def _wrapped(content):
        counts["count"] += 1
        return original(content)

    # Observe the serialisation itself, not the identity lookups: every key
    # derivation asks for the identity, but a list must be serialised only once
    # per merge call.
    monkeypatch.setattr(models, "_canonical_structured_content", _wrapped)
    return counts


def _install_key_wrappers(monkeypatch):
    call_counts: dict[str, dict[int, int]] = {
        "merge": defaultdict(int),
        "dedup": defaultdict(int),
        "content": defaultdict(int),
        "visible": defaultdict(int),
    }

    def _install(name):
        original = getattr(models, f"_session_message_{name}_key")

        def _wrapped(message, *args, **kwargs):
            if isinstance(message, dict):
                call_counts[name][id(message)] += 1
            return original(message, *args, **kwargs)

        monkeypatch.setattr(models, f"_session_message_{name}_key", _wrapped)

    for name in call_counts:
        _install(name)

    return call_counts


def test_merge_append_only_caches_canonical_keys_and_preserves_identity(monkeypatch):
    """Verify exact key-call fanout and output identity for shared row objects."""
    str_calls = {"count": 0}
    repeated_user_content = _CountingList(["hello", "world"], str_counter=str_calls)
    repeated_assistant_content = _CountingList(["reply", "with", "spacing"], str_counter=str_calls)

    repeated_user = {
        "role": "user",
        "content": repeated_user_content,
        "timestamp": 1000,
    }
    repeated_assistant = {
        "role": "assistant",
        "content": repeated_assistant_content,
        "timestamp": 1001,
    }
    sidecar_messages = [repeated_user, repeated_user, repeated_user, repeated_assistant]
    state_messages = [repeated_assistant, repeated_user]

    normalizations = _count_content_normalizations(monkeypatch)
    call_counts = _install_key_wrappers(monkeypatch)
    merged = models.merge_session_messages_append_only(sidecar_messages, state_messages)

    assert [id(msg) for msg in merged] == [
        id(repeated_user),
        id(repeated_user),
        id(repeated_user),
        id(repeated_assistant),
    ]
    assert merged == [repeated_user, repeated_user, repeated_user, repeated_assistant]

    assert normalizations["count"] == 2
    assert str_calls["count"] == 0
    assert dict(call_counts["merge"]) == {
        id(repeated_user): 1,
        id(repeated_assistant): 1,
    }
    assert len(dict(call_counts["dedup"])) == 2
    assert len(dict(call_counts["content"])) == 2
    assert len(dict(call_counts["visible"])) == 2
    assert set(dict(call_counts["dedup"]).values()) == {1}
    # Source-aware reconciliation deliberately computes content/visible keys
    # once for sidecar ownership and once for state.db ownership. Reusing one
    # cached key here would reintroduce the literal-workspace-prefix collision.
    assert set(dict(call_counts["content"]).values()) == {2}
    assert set(dict(call_counts["visible"]).values()) == {2}


def test_merge_append_only_helpers_do_not_see_mutated_content(monkeypatch):
    original_content = _CountingList(["streamed", "content"], str_counter={"count": 0})
    sidecar_messages = [
        {
            "role": "user",
            "content": original_content,
            "timestamp": 1000,
        }
    ]
    state_messages = [
        {
            "role": "assistant",
            "content": "ack",
            "timestamp": 1001,
        }
    ]

    observed = {"helpers_invoked": False}
    call_counts: dict[str, dict[int, int]] = {
        "merge": defaultdict(int),
        "dedup": defaultdict(int),
        "content": defaultdict(int),
        "visible": defaultdict(int),
    }

    def _install(name, *, assert_content_content=False):
        original = getattr(models, f"_session_message_{name}_key")

        def _wrapped(message, *args, **kwargs):
            if isinstance(message, dict):
                call_counts[name][id(message)] += 1
            if assert_content_content and isinstance(message, dict):
                # Verify the aliased sidecar dict still owns the original object
                # during helper execution.
                assert sidecar_messages[0]["content"] is original_content
                observed["helpers_invoked"] = True
            return original(message, *args, **kwargs)

        monkeypatch.setattr(models, f"_session_message_{name}_key", _wrapped)

    _install("merge")
    _install("dedup")
    _install("visible")
    _install("content", assert_content_content=True)

    models.merge_session_messages_append_only(sidecar_messages, state_messages)

    assert observed["helpers_invoked"] is True
    assert len(dict(call_counts["content"])) == 2
    assert set(dict(call_counts["content"]).values()) == {1}


def test_merge_append_only_state_only_stable_id_does_not_stringify_content(monkeypatch):
    """State-only merge keeps stable-ID rows on the helper fast path."""
    str_calls = {"count": 0}
    message = {
        "id": "stable-id",
        "role": "assistant",
        "content": _ExplodingList(["danger", "zone"], str_counter=str_calls),
        "timestamp": 2000,
    }

    call_counts = _install_key_wrappers(monkeypatch)
    merged = models.merge_session_messages_append_only([], [message])

    assert merged == [message]
    assert merged[0] is message
    assert str_calls["count"] == 0
    assert dict(call_counts["merge"]) == {}
    assert dict(call_counts["dedup"]) == {
        id(message): 1,
    }
    assert dict(call_counts["content"]) == {}
    assert dict(call_counts["visible"]) == {}


def test_merge_append_only_distinct_equal_dicts_remain_distinct_inputs(monkeypatch):
    """Two equal dict rows still pass through as separate inputs."""
    msg_1 = {
        "role": "user",
        "content": "same",
        "timestamp": 1000,
    }
    msg_2 = {
        "role": "user",
        "content": "same",
        "timestamp": 1000,
    }

    call_counts = _install_key_wrappers(monkeypatch)
    merged = models.merge_session_messages_append_only([], [msg_1, msg_2])

    assert merged == [msg_1]
    assert merged[0] is msg_1
    assert dict(call_counts["dedup"]) == {
        id(msg_1): 1,
        id(msg_2): 1,
    }
    assert dict(call_counts["merge"]) == {}


def test_merge_append_only_recomputes_after_mutation(monkeypatch):
    """Per-call key caches reset; mutation between calls changes work input."""
    str_calls = {"count": 0}
    state_message = {
        "role": "assistant",
        "content": _CountingList(["first"], str_counter=str_calls),
        "timestamp": 3000,
    }

    normalizations = _count_content_normalizations(monkeypatch)
    call_counts = _install_key_wrappers(monkeypatch)
    first = models.merge_session_messages_append_only([], [state_message])
    state_message["content"].append("second")
    second = models.merge_session_messages_append_only([], [state_message])

    assert first == [state_message]
    assert second == [state_message]
    assert first is not second
    assert normalizations["count"] == 2
    assert str_calls["count"] == 0
    assert dict(call_counts["dedup"]) == {
        id(state_message): 2,
    }
    assert dict(call_counts["merge"]) == {}
    assert dict(call_counts["content"]) == {}
    assert dict(call_counts["visible"]) == {}
