"""Regression tests for the state.db structured-content sentinel.

hermes_state stores list/dict message content as a NUL-sentinel JSON string.
While the WebUI projector left it undecoded, an uploaded image's base64 data
URI reached the transcript as literal text -- one unbreakable ~65k-character
run -- and the browser spent minutes computing its min-content width.

The decode is deliberately narrow: it must not widen ``content`` into any
shape the rest of the WebUI pipeline cannot already render.
"""
import json

from api.models import (
    _content_identity_for_key,
    _decode_state_db_content,
    _project_state_db_message,
    _session_message_dedup_key,
    _session_message_merge_key,
    _session_message_multimodal_mirror_key,
    _session_message_visible_key,
)

PREFIX = "\x00json:"
TEXT_AND_IMAGE = [
    {"type": "text", "text": "here is a screenshot"},
    {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]


def _sentinel(payload_json: str) -> str:
    return PREFIX + payload_json


# --- the fix itself -------------------------------------------------------

def test_supported_list_root_is_decoded():
    assert _decode_state_db_content(_sentinel(json.dumps(TEXT_AND_IMAGE))) == TEXT_AND_IMAGE


def test_projector_decodes_content():
    row = {"role": "user", "content": _sentinel(json.dumps(TEXT_AND_IMAGE)),
           "timestamp": 1.0, "id": 1}
    msg = _project_state_db_message(row, available=set(), id_col=False, optional=())
    assert msg["content"] == TEXT_AND_IMAGE


# --- finding #1: dict roots must NOT be widened ---------------------------

def test_dict_root_falls_back_to_raw_string():
    """A dict root reaches _renderCacheKey(), which calls text.slice() on it
    and throws, blanking the turn. It must stay a string."""
    raw = _sentinel(json.dumps({"type": "text", "text": "hi"}))
    assert _decode_state_db_content(raw) == raw


def test_scalar_roots_fall_back_to_raw_string():
    for payload in ("42", '"just a string"', "true", "null"):
        raw = _sentinel(payload)
        assert _decode_state_db_content(raw) == raw


# --- finding #2: non-finite numbers break browser JSON.parse --------------

def test_non_finite_constants_fall_back_to_raw_string():
    for literal in ("NaN", "Infinity", "-Infinity"):
        raw = _sentinel('[{"type": "text", "text": 1}, %s]' % literal)
        assert _decode_state_db_content(raw) == raw


def test_overflowed_float_falls_back_to_raw_string():
    raw = _sentinel('[{"type": "text", "text": "x", "score": 1e400}]')
    assert _decode_state_db_content(raw) == raw


def test_decoded_payload_is_always_browser_parseable():
    decoded = _decode_state_db_content(_sentinel(json.dumps(TEXT_AND_IMAGE)))
    json.loads(json.dumps(decoded, allow_nan=False))


# --- finding #3: only the schema the UI actually renders ------------------

def test_unsupported_part_shapes_fall_back_to_raw_string():
    unsupported = [
        [{"type": "input_text", "text": "dropped by the JS readers"}],
        [{"type": "output_text", "text": "also dropped"}],
        [{"type": "tool_use", "id": "t1"}],
        ["a bare scalar part"],
        [{"text": "no type key"}],
        [{"type": "text", "text": {"not": "a string"}}],
        [],
    ]
    for payload in unsupported:
        raw = _sentinel(json.dumps(payload))
        assert _decode_state_db_content(raw) == raw, payload


def test_image_only_list_stays_raw_because_it_would_not_render():
    """msgContent() discards image parts and this projection supplies no
    attachments, so an image-only row would decode to nothing visible and
    _messageIsRenderable() would hide it. It must stay a raw string instead."""
    payload = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}]
    raw = _sentinel(json.dumps(payload))
    assert _decode_state_db_content(raw) == raw


# --- passthrough / malformed ---------------------------------------------

def test_plain_values_pass_through_unchanged():
    for value in ("hello", "", None, 42, ["already", "a", "list"], "see json: below"):
        assert _decode_state_db_content(value) == value


def test_malformed_sentinel_payload_returns_raw_string():
    raw = _sentinel("{not valid json")
    assert _decode_state_db_content(raw) == raw


# --- finding #4: identities must be type-namespaced ----------------------

def test_structured_content_cannot_collide_with_its_own_repr():
    structured = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1.0}
    scalar = {"role": "user", "content": str(TEXT_AND_IMAGE), "timestamp": 1.0}
    assert _session_message_merge_key(structured) != _session_message_merge_key(scalar)
    assert _session_message_dedup_key(structured) != _session_message_dedup_key(scalar)


def test_rich_turns_with_different_images_keep_distinct_identities():
    def turn(url):
        return {"role": "user", "timestamp": 1.0, "content": [
            {"type": "text", "text": "same visible text"},
            {"type": "image_url", "image_url": {"url": url}},
        ]}
    a, b = turn("data:image/png;base64,AAAA"), turn("data:image/png;base64,BBBB")
    assert _session_message_merge_key(a) != _session_message_merge_key(b)
    assert _session_message_dedup_key(a) != _session_message_dedup_key(b)


def test_scalar_content_identity_is_unchanged():
    """Existing scalar behaviour must not shift."""
    assert _content_identity_for_key("plain text") == "plain text"
    assert _content_identity_for_key(None) == ""
    assert _content_identity_for_key("") == ""


def test_mirror_bridge_never_pairs_rich_to_rich():
    rich = {"role": "user", "timestamp": 1.0, "content": TEXT_AND_IMAGE}
    assert _session_message_multimodal_mirror_key(rich, require_image_parts=True) is not None
    # the scalar side of the bridge must refuse a rich row
    assert _session_message_multimodal_mirror_key(rich, require_scalar_mirror=True) is None
    # and the two flags are mutually exclusive by construction
    assert _session_message_multimodal_mirror_key(
        rich, require_image_parts=True, require_scalar_mirror=True
    ) is None


def test_mirror_bridge_still_accepts_a_scalar_mirror():
    scalar = {"role": "user", "timestamp": 1.0, "content": "[screenshot] here is a screenshot"}
    assert _session_message_multimodal_mirror_key(scalar, require_scalar_mirror=True) is not None


# --- finding #5: prefix and tail keys share one representation -----------

def test_prefix_and_tail_keys_agree_for_a_sentinel_row():
    raw = _sentinel(json.dumps(TEXT_AND_IMAGE))
    row = {"role": "user", "content": raw, "timestamp": 5.0, "id": 7}
    tail_msg = _project_state_db_message(row, available=set(), id_col=False, optional=())
    prefix_msg = {
        "role": row["role"],
        "content": _decode_state_db_content(row["content"]),
        "tool_calls": None,
        "api_content": None,
    }
    tail_key = _session_message_visible_key(
        {"role": tail_msg.get("role"), "content": tail_msg.get("content"),
         "tool_calls": None, "api_content": None},
        normalize_workspace_prefix=True,
    )
    prefix_key = _session_message_visible_key(prefix_msg, normalize_workspace_prefix=True)
    assert prefix_key == tail_key


# --- every read path that projects content must decode (behavioural) ------
#
# Keys derived on one read path are compared against keys derived on another,
# so a read path that projected the column raw while another decoded it would
# silently reintroduce the prefix/tail mismatch. These exercise each path
# against a real sentinel-encoded row rather than inspecting source.

def _make_state_db(path, sid, rows):
    import sqlite3
    conn = sqlite3.connect(path)
    conn.execute(
        "CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, title TEXT, "
        "model TEXT, started_at REAL, message_count INTEGER)"
    )
    conn.execute(
        "CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, "
        "role TEXT, content TEXT, timestamp REAL, tool_call_id TEXT, tool_calls TEXT, "
        "tool_name TEXT, active INTEGER DEFAULT 1, api_content TEXT)"
    )
    conn.execute(
        "INSERT INTO sessions (id, source, title, model, started_at, message_count) "
        "VALUES (?, ?, ?, ?, ?, ?)",
        (sid, "webui", "Sentinel", "test-model", 1000.0, len(rows)),
    )
    for row in rows:
        conn.execute(
            "INSERT INTO messages (session_id, role, content, timestamp, active) "
            "VALUES (?, ?, ?, ?, 1)",
            (sid, row["role"], row["content"], row["timestamp"]),
        )
    conn.commit()
    conn.close()


def _sentinel_session(tmp_path, monkeypatch):
    """A session whose first row carries sentinel-encoded multimodal content."""
    from api import models

    sid = "sentineltest"
    db = tmp_path / "state.db"
    _make_state_db(db, sid, [
        {"role": "user", "content": _sentinel(json.dumps(TEXT_AND_IMAGE)), "timestamp": 1000.0},
        {"role": "assistant", "content": "plain reply", "timestamp": 2000.0},
    ])
    monkeypatch.setattr(models, "_active_state_db_path", lambda: db, raising=False)
    return models, sid


def test_transcript_read_decodes_sentinel_rows(tmp_path, monkeypatch):
    """The canonical projection hands the frontend structured content."""
    models, sid = _sentinel_session(tmp_path, monkeypatch)
    messages = models.get_state_db_session_messages(sid)
    assert messages, "expected the fixture session to load"
    assert messages[0]["content"] == TEXT_AND_IMAGE
    # the base64 payload must not survive as literal transcript text
    assert not isinstance(messages[0]["content"], str)


def test_regeneration_prefix_and_tail_keys_agree_for_a_sentinel_row(tmp_path, monkeypatch):
    """The same row keyed as prefix and as tail must produce one identity.

    Decoding the projected tail while leaving prefix keys encoded would make
    `_bounded_tail_snapshot_if_safe` reject the bounded path and re-read the
    whole transcript, and could hide a genuine repeated recovered turn.
    """
    models, sid = _sentinel_session(tmp_path, monkeypatch)

    # floor above the sentinel row -> it is part of the prefix proof
    as_prefix = models.get_state_db_regeneration_tail_snapshot(sid, 1500.0)
    # floor below it -> the same row is part of the bounded tail
    as_tail = models.get_state_db_regeneration_tail_snapshot(sid, 500.0)
    assert as_prefix is not None and as_tail is not None

    assert as_prefix["prefix_keys"], "sentinel row should sit in the prefix"
    assert as_tail["tail_keys"], "sentinel row should sit in the tail"
    assert as_prefix["prefix_keys"][0] == as_tail["tail_keys"][0]


def test_bounded_prefix_reader_agrees_with_the_projected_tail(tmp_path, monkeypatch):
    """The standalone prefix-key reader shares the tail's representation."""
    models, sid = _sentinel_session(tmp_path, monkeypatch)

    prefix_keys = models.get_state_db_session_message_keys_before_timestamp(sid, 1500.0)
    tail = models.get_state_db_regeneration_tail_snapshot(sid, 500.0)
    assert prefix_keys, "expected a prefix key for the sentinel row"
    assert tail is not None and tail["tail_keys"]
    assert prefix_keys[0] == tail["tail_keys"][0]


def test_unsupported_sentinel_shape_survives_the_read_path_as_text(tmp_path, monkeypatch):
    """A shape the UI cannot render stays a string end-to-end, not silently dropped."""
    from api import models

    sid = "unsupported"
    db = tmp_path / "state.db"
    raw = _sentinel(json.dumps({"type": "text", "text": "dict root"}))
    _make_state_db(db, sid, [{"role": "user", "content": raw, "timestamp": 1000.0}])
    monkeypatch.setattr(models, "_active_state_db_path", lambda: db, raising=False)

    messages = models.get_state_db_session_messages(sid)
    assert messages
    assert messages[0]["content"] == raw


# --- re-gate finding 2: only decode what will actually render --------------

def test_whitespace_only_text_with_an_image_stays_raw():
    payload = [
        {"type": "text", "text": "   \n\t "},
        {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}},
    ]
    raw = _sentinel(json.dumps(payload))
    assert _decode_state_db_content(raw) == raw


def test_malformed_image_parts_stay_raw():
    text = {"type": "text", "text": "caption"}
    malformed = [
        {"type": "image_url", "payload": "invalid"},
        {"type": "image_url", "image_url": {"url": ""}},
        {"type": "image_url", "image_url": {"href": "x"}},
        {"type": "input_image"},
        {"type": "image", "source": {"type": "base64", "data": "AA"}},  # no media_type
        {"type": "image", "source": {"type": "url"}},
        {"type": "image", "source": "not-a-dict"},
    ]
    for bad in malformed:
        raw = _sentinel(json.dumps([text, bad]))
        assert _decode_state_db_content(raw) == raw, bad


def test_text_with_each_valid_image_payload_shape_decodes():
    text = {"type": "text", "text": "caption"}
    valid = [
        {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}},
        {"type": "image_url", "image_url": "https://example.test/a.png"},
        {"type": "input_image", "image_url": "data:image/png;base64,AA"},
        {"type": "input_image", "file_id": "file-123"},
        {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA"}},
        {"type": "image", "source": {"type": "url", "url": "https://example.test/a.png"}},
    ]
    for good in valid:
        payload = [text, good]
        assert _decode_state_db_content(_sentinel(json.dumps(payload))) == payload, good


def test_image_only_row_stays_visible_through_the_read_path(tmp_path, monkeypatch):
    from api import models

    sid = "imageonly"
    db = tmp_path / "state.db"
    raw = _sentinel(json.dumps([{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}]))
    _make_state_db(db, sid, [{"role": "user", "content": raw, "timestamp": 1000.0}])
    monkeypatch.setattr(models, "_active_state_db_path", lambda: db, raising=False)
    messages = models.get_state_db_session_messages(sid)
    assert messages and messages[0]["content"] == raw


# --- re-gate finding 1: out-of-band identity, consistent across every key ---

from api.models import (  # noqa: E402
    _matching_visible_duplicate,
    _session_message_content_key,
    merge_session_messages_append_only,
)

# Exactly what the previous in-band tag produced for TEXT_AND_IMAGE.
_OLD_INBAND_TOKEN = "\x00list:" + json.dumps(TEXT_AND_IMAGE, sort_keys=True, default=str)


def test_structured_identity_is_not_a_string():
    """Out of band: no message body can equal it, whatever it contains."""
    assert not isinstance(_content_identity_for_key(TEXT_AND_IMAGE), str)


def test_scalar_imitating_a_structured_identity_collides_with_no_key():
    rich = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1.0}
    forged = {"role": "user", "content": _OLD_INBAND_TOKEN, "timestamp": 1.0}
    assert _session_message_merge_key(rich) != _session_message_merge_key(forged)
    assert _session_message_dedup_key(rich) != _session_message_dedup_key(forged)
    assert _session_message_content_key(rich) != _session_message_content_key(forged)
    assert _session_message_visible_key(rich) != _session_message_visible_key(forged)


def test_merge_keeps_the_rich_row_when_a_scalar_imitates_its_identity():
    rich = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1000.0}
    forged = {"role": "user", "content": _OLD_INBAND_TOKEN, "timestamp": 1000.0}
    merged = merge_session_messages_append_only([forged], [rich])
    assert any(m.get("content") == TEXT_AND_IMAGE for m in merged), merged


def test_non_list_scalars_key_exactly_as_on_master():
    """No silent dedup change for ordinary non-string content."""
    assert _content_identity_for_key(42) == "42"
    assert _content_identity_for_key(3.5) == "3.5"
    assert _content_identity_for_key(True) == "True"
    assert _content_identity_for_key({"a": 1}) == str({"a": 1})
    assert _content_identity_for_key([]) == ""
    msg = {"role": "user", "content": 42, "timestamp": 1.0}
    assert "42" in _session_message_merge_key(msg)


def test_structured_visible_key_never_fuzzy_matches_a_scalar():
    rich_key = _session_message_visible_key({"role": "user", "content": TEXT_AND_IMAGE})
    canonical = _content_identity_for_key(TEXT_AND_IMAGE)[1]
    for text in (canonical, "prefix " + canonical + " suffix", str(TEXT_AND_IMAGE)):
        scalar_key = _session_message_visible_key({"role": "user", "content": text})
        assert _matching_visible_duplicate(rich_key, {scalar_key}) is None
        assert _matching_visible_duplicate(scalar_key, {rich_key}) is None


def test_merge_never_writes_an_identity_into_message_content():
    rich = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1000.0}
    reply = {"role": "assistant", "content": "ok", "timestamp": 1001.0}
    merged = merge_session_messages_append_only([rich], [rich, reply])
    for message in merged:
        assert isinstance(message.get("content"), (str, list))
        assert not isinstance(message.get("content"), tuple)
    assert any(m.get("content") == TEXT_AND_IMAGE for m in merged)


def test_structured_content_is_serialised_once_per_merge_call(monkeypatch):
    from api import models

    calls = {"n": 0}
    real = models._canonical_structured_content

    def counting(content):
        calls["n"] += 1
        return real(content)

    monkeypatch.setattr(models, "_canonical_structured_content", counting)
    rich = {"role": "user", "content": list(TEXT_AND_IMAGE), "timestamp": 1000.0}
    merge_session_messages_append_only([rich, rich], [rich])
    assert calls["n"] == 1
