"""Regression tests for model-facing context-history sanitization."""
from __future__ import annotations

import json
import threading
from types import SimpleNamespace
from unittest.mock import patch

from api.streaming import (
    _api_safe_message_positions,
    _compact_image_parts_for_persistence,
    _compact_session_image_parts_for_persistence,
    _restore_reasoning_metadata,
    _sanitize_messages_for_api,
    _strip_oob_blocks,
)


OOB_BLOCK = (
    "[OUT-OF-BAND USER MESSAGE — consumed steer]\n"
    "internal control text that must not reach the model\n"
    "[/OUT-OF-BAND USER MESSAGE]"
)


def _contains_oob(value) -> bool:
    return "OUT-OF-BAND USER MESSAGE" in json.dumps(value)



def test_compact_image_parts_for_persistence_keeps_text_and_drops_base64():
    image_data_url = "data:image/png;base64," + ("A" * 1_000_000)
    messages = [
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [{"id": "vision-1"}],
        },
        {
            "role": "tool",
            "tool_call_id": "vision-1",
            "content": [
                {"type": "text", "text": "Image loaded into your context."},
                {"type": "image_url", "image_url": {"url": image_data_url}},
            ],
        },
    ]

    changed = _compact_image_parts_for_persistence(messages)

    assert changed == 1
    assert messages[1]["content"] == [
        {"type": "text", "text": "Image loaded into your context."},
        {"type": "text", "text": "[screenshot]"},
    ]
    assert "data:image" not in json.dumps(messages)
    assert messages[0]["tool_calls"] == [{"id": "vision-1"}]




def test_compact_image_parts_for_persistence_counts_each_image_part():
    messages = [
        {
            "role": "tool",
            "content": [
                {"type": "text", "text": "Before and after"},
                {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
                {"type": "input_image", "image_url": {"url": "data:image/png;base64,BBBB"}},
            ],
        }
    ]

    changed = _compact_image_parts_for_persistence(messages)

    assert changed == 2
    assert messages[0]["content"] == [
        {"type": "text", "text": "Before and after"},
        {"type": "text", "text": "[screenshot]"},
        {"type": "text", "text": "[screenshot]"},
    ]


def test_compact_image_parts_for_persistence_preserves_interleaved_non_image_parts():
    document = {"type": "document", "document": {"name": "report.pdf", "id": "doc-1"}}
    scalar = "provider extension payload"
    messages = [
        {
            "role": "tool",
            "content": [
                {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
                {"type": "text", "text": "between"},
                document,
                scalar,
                {"type": "input_image", "image_url": {"url": "data:image/png;base64,BBBB"}},
                {"type": "input_text", "content": "after"},
            ],
        }
    ]

    changed = _compact_image_parts_for_persistence(messages)

    assert changed == 2
    assert messages[0]["content"] == [
        {"type": "text", "text": "[screenshot]"},
        {"type": "text", "text": "between"},
        document,
        scalar,
        {"type": "text", "text": "[screenshot]"},
        {"type": "input_text", "content": "after"},
    ]
    assert "data:image" not in json.dumps(messages)
    assert _compact_image_parts_for_persistence(messages) == 0


def test_compact_image_parts_for_persistence_survives_unhashable_part_type():
    # A JSON-valid part can carry a non-string (unhashable) ``type`` such as a
    # list or dict. ``part.get("type") in {...}`` would raise TypeError on an
    # unhashable value, turning an otherwise-complete streaming send into the
    # error path before the session is saved. Such parts must be preserved
    # unchanged, and real image parts alongside them must still compact.
    list_type = {"type": ["provider-extension"], "data": "x"}
    dict_type = {"type": {"nested": "kind"}, "data": "y"}
    messages = [
        {
            "role": "tool",
            "content": [
                list_type,
                {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
                dict_type,
            ],
        }
    ]

    changed = _compact_image_parts_for_persistence(messages)

    assert changed == 1
    assert messages[0]["content"] == [
        list_type,
        {"type": "text", "text": "[screenshot]"},
        dict_type,
    ]
    assert "data:image" not in json.dumps(messages)
    # Idempotent: a second pass changes nothing and still does not raise.
    assert _compact_image_parts_for_persistence(messages) == 0


def test_compact_session_image_parts_for_persistence_compacts_both_histories():
    image = {"type": "image_url", "image_url": {"url": "data:image/png;base64," + ("A" * 4096)}}
    session = SimpleNamespace(
        session_id="vision-turn",
        messages=[{"role": "tool", "content": [{"type": "text", "text": "display"}, image.copy()]}],
        context_messages=[{"role": "tool", "content": [{"type": "text", "text": "context"}, image.copy()]}],
    )

    changed = _compact_session_image_parts_for_persistence(session)

    assert changed == 2
    assert session.messages[0]["content"] == [
        {"type": "text", "text": "display"},
        {"type": "text", "text": "[screenshot]"},
    ]
    assert session.context_messages[0]["content"] == [
        {"type": "text", "text": "context"},
        {"type": "text", "text": "[screenshot]"},
    ]


def test_compact_image_parts_for_persistence_keeps_user_image_attachment():
    messages = [
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "Inspect this attachment"},
                {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
            ],
        }
    ]

    changed = _compact_image_parts_for_persistence(messages)

    assert changed == 0
    assert messages[0]["content"][1]["type"] == "image_url"


def test_compact_image_parts_for_persistence_leaves_text_history_unchanged():
    messages = [{"role": "tool", "content": "plain tool output"}]

    changed = _compact_image_parts_for_persistence(messages)

    assert changed == 0
    assert messages == [{"role": "tool", "content": "plain tool output"}]


def test_strip_oob_blocks_string():
    content = f"visible before\n{OOB_BLOCK}\nvisible after"

    cleaned = _strip_oob_blocks(content)

    assert "visible before" in cleaned
    assert "visible after" in cleaned
    assert "internal control text" not in cleaned
    assert "OUT-OF-BAND USER MESSAGE" not in cleaned


def test_strip_oob_blocks_list_content():
    content = [
        {"type": "text", "text": f"keep\n{OOB_BLOCK}\nkeep later"},
        {"type": "input_text", "content": f"prefix\n{OOB_BLOCK}\nsuffix"},
        {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
    ]

    cleaned = _strip_oob_blocks(content)

    assert cleaned[0]["text"].startswith("keep")
    assert "keep later" in cleaned[0]["text"]
    assert "suffix" in cleaned[1]["content"]
    assert cleaned[2] == content[2]
    assert not _contains_oob(cleaned)


def test_sanitize_messages_for_api_no_oob_in_output():
    messages = [
        {"role": "user", "content": f"question\n{OOB_BLOCK}\nvisible question"},
        {
            "role": "assistant",
            "content": f"answer\n{OOB_BLOCK}\nvisible answer",
            "reasoning": "display-only reasoning",
            "thinking": "display-only thinking",
            "_reasoning": "display-only private reasoning",
            "reasoning_content": "provider-facing reasoning metadata",
        },
    ]

    sanitized = _sanitize_messages_for_api(messages)

    assert not _contains_oob(sanitized)
    assert sanitized[0]["content"].startswith("question")
    assert "visible question" in sanitized[0]["content"]
    assert "visible answer" in sanitized[1]["content"]
    assert "reasoning" not in sanitized[1]
    assert "thinking" not in sanitized[1]
    assert "_reasoning" not in sanitized[1]
    assert sanitized[1]["reasoning_content"] == "provider-facing reasoning metadata"


def test_sanitize_messages_for_api_preserves_tool_chains():
    messages = [
        {"role": "user", "content": f"please inspect\n{OOB_BLOCK}\nvisible request"},
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "type": "function",
                    "id": "call-1",
                    "function": {"name": "terminal", "arguments": "{}"},
                }
            ],
        },
        {"role": "tool", "tool_call_id": "call-1", "content": f"tool output\n{OOB_BLOCK}\nresult"},
        {"role": "assistant", "content": "done"},
    ]

    sanitized = _sanitize_messages_for_api(messages)

    assert [msg["role"] for msg in sanitized] == ["user", "assistant", "tool", "assistant"]
    assert sanitized[1]["tool_calls"][0]["id"] == "call-1"
    assert sanitized[2]["tool_call_id"] == "call-1"
    assert "result" in sanitized[2]["content"]
    assert not _contains_oob(sanitized)


def test_sanitize_messages_drops_empty_tool_calls_array():
    """#5737: strict providers (DeepSeek v4, newer OpenAI) reject an assistant
    message carrying `tool_calls: []` with HTTP 400. A stored assistant message
    can literally have an empty list (not None, not missing), which the orphan
    linker doesn't catch. The sanitizer must drop the empty key while leaving a
    populated tool_calls chain intact."""
    messages = [
        {"role": "user", "content": "hi"},
        # Empty tool_calls: [] must be dropped (would 400 on strict providers).
        {"role": "assistant", "content": "thinking out loud", "tool_calls": []},
        {"role": "user", "content": "go on"},
        # Populated tool_calls (+ its tool result) must be preserved untouched.
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {"type": "function", "id": "call-9",
                 "function": {"name": "terminal", "arguments": "{}"}},
            ],
        },
        {"role": "tool", "tool_call_id": "call-9", "content": "ok"},
    ]

    sanitized = _sanitize_messages_for_api(messages)

    # The empty-tool_calls assistant message survives, but WITHOUT the key.
    empty_asst = next(m for m in sanitized if m.get("content") == "thinking out loud")
    assert empty_asst["role"] == "assistant"
    assert "tool_calls" not in empty_asst, "empty tool_calls: [] must be dropped (strict-provider 400)"
    # The populated chain is preserved.
    populated = next(m for m in sanitized if m.get("role") == "assistant" and m.get("tool_calls"))
    assert populated["tool_calls"][0]["id"] == "call-9"
    assert any(m.get("tool_call_id") == "call-9" for m in sanitized)
    # No message that reaches the API carries an empty tool_calls array.
    for m in sanitized:
        assert not ("tool_calls" in m and not m["tool_calls"]), \
            "no message may ship an empty tool_calls array"


def test_api_safe_message_positions_drops_empty_tool_calls_array():
    """#5737 mirror path: _api_safe_message_positions must apply the same
    empty-tool_calls drop as _sanitize_messages_for_api, so metadata
    restoration (which aligns rows against these positions) never treats a
    tool_calls: [] row as a different message. Empty array -> key removed;
    populated tool_calls chain -> preserved."""
    messages = [
        {"role": "user", "content": "hi"},
        # Empty tool_calls: [] must be dropped (would 400 on strict providers).
        {"role": "assistant", "content": "thinking out loud", "tool_calls": []},
        {"role": "user", "content": "go on"},
        # Populated tool_calls (+ its tool result) must be preserved untouched.
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {"type": "function", "id": "call-9",
                 "function": {"name": "terminal", "arguments": "{}"}},
            ],
        },
        {"role": "tool", "tool_call_id": "call-9", "content": "ok"},
    ]

    positions = _api_safe_message_positions(messages)

    sanitized = [msg for _idx, msg in positions]
    # The empty-tool_calls assistant message survives, but WITHOUT the key.
    empty_asst = next(m for m in sanitized if m.get("content") == "thinking out loud")
    assert empty_asst["role"] == "assistant"
    assert "tool_calls" not in empty_asst, "empty tool_calls: [] must be dropped"
    # The populated chain is preserved.
    populated = next(m for m in sanitized if m.get("role") == "assistant" and m.get("tool_calls"))
    assert populated["tool_calls"][0]["id"] == "call-9"
    assert any(m.get("tool_call_id") == "call-9" for m in sanitized)
    # No row that reaches the API carries an empty tool_calls array.
    for _idx, msg in positions:
        assert not ("tool_calls" in msg and not msg["tool_calls"]), \
            "no API-safe position may ship an empty tool_calls array"


def test_gateway_conversation_history_no_oob():
    from api.config import STREAM_PARTIAL_TEXT, STREAM_REASONING_TEXT
    from api.gateway_chat import _STREAM_RUN_IDS, _run_gateway_runs_api_streaming

    requests = []
    stream_id = "stream-oob-sanitization"
    STREAM_PARTIAL_TEXT[stream_id] = ""
    STREAM_REASONING_TEXT[stream_id] = ""

    class _JsonResponse:
        def __init__(self, payload):
            self._payload = json.dumps(payload).encode("utf-8")

        def read(self, _limit=None):
            return self._payload

        def __enter__(self):
            return self

        def __exit__(self, *args):
            return None

    class _SseResponse:
        def __iter__(self):
            return iter([
                b'data: {"event":"run.completed","output":"done","usage":{"input_tokens":1,"output_tokens":1}}\n',
                b'\n',
            ])

        def __enter__(self):
            return self

        def __exit__(self, *args):
            return None

    def fake_urlopen(req, *, timeout=None):
        requests.append(req)
        if req.full_url.endswith("/v1/runs"):
            return _JsonResponse({"run_id": "run-oob"})
        return _SseResponse()

    session = SimpleNamespace(context_messages=[
        {"role": "system", "content": f"ignored system\n{OOB_BLOCK}"},
        {"role": "user", "content": f"history request\n{OOB_BLOCK}\nvisible request"},
        {"role": "assistant", "content": [{"type": "text", "text": f"history reply\n{OOB_BLOCK}\nvisible reply"}]},
        {"role": "tool", "tool_call_id": "call-ignored", "content": f"ignored\n{OOB_BLOCK}"},
    ])

    try:
        with patch("urllib.request.urlopen", side_effect=fake_urlopen):
            final_text, usage = _run_gateway_runs_api_streaming(
                session_id="sess-oob",
                msg_text="current user",
                model="test-model",
                workspace="/tmp",
                stream_id=stream_id,
                base_url="http://gw:8642",
                api_key="secret",
                prefill_messages=[],
                body_extras={},
                put_gateway_event=lambda *_args, **_kwargs: None,
                cancel_event=threading.Event(),
                session=session,
            )
    finally:
        STREAM_PARTIAL_TEXT.pop(stream_id, None)
        STREAM_REASONING_TEXT.pop(stream_id, None)
        _STREAM_RUN_IDS.pop(stream_id, None)

    run_body = json.loads(requests[0].data.decode("utf-8"))
    assert final_text == "done"
    assert usage["input_tokens"] == 1
    assert usage["output_tokens"] == 1
    assert not _contains_oob(run_body["conversation_history"])
    assert run_body["conversation_history"] == [
        {"role": "user", "content": "history request\n\nvisible request"},
        {"role": "assistant", "content": [{"type": "text", "text": "history reply\n\nvisible reply"}]},
    ]


# ── Regex variant coverage tests ─────────────────────────────────────────────

OOB_HYPHEN = (
    "[OUT-OF-BAND USER MESSAGE - consumed steer]\n"
    "internal control text that must not reach the model\n"
    "[/OUT-OF-BAND USER MESSAGE]"
)


def test_strip_oob_blocks_hyphen_marker():
    """Hyphen variant of opening marker should be stripped."""
    content = f"visible before\n{OOB_HYPHEN}\nvisible after"

    cleaned = _strip_oob_blocks(content)

    assert "visible before" in cleaned
    assert "visible after" in cleaned
    assert "internal control text" not in cleaned
    assert "OUT-OF-BAND USER MESSAGE" not in cleaned


OOB_CRLF = (
    "[OUT-OF-BAND USER MESSAGE — consumed steer]\r\n"
    "internal control text that must not reach the model\r\n"
    "[/OUT-OF-BAND USER MESSAGE]"
)


def test_strip_oob_blocks_crlf_line_endings():
    """Windows CRLF line endings should be handled."""
    content = f"visible before\r\n{OOB_CRLF}\r\nvisible after"

    cleaned = _strip_oob_blocks(content)

    assert "visible before" in cleaned
    assert "visible after" in cleaned
    assert "internal control text" not in cleaned
    assert "OUT-OF-BAND USER MESSAGE" not in cleaned


OOB_NO_SUFFIX = (
    "[OUT-OF-BAND USER MESSAGE]\n"
    "internal control text that must not reach the model\n"
    "[/OUT-OF-BAND USER MESSAGE]"
)


def test_strip_oob_blocks_no_suffix_marker():
    """Opening marker without dash/suffix should be stripped."""
    content = f"visible before\n{OOB_NO_SUFFIX}\nvisible after"

    cleaned = _strip_oob_blocks(content)

    assert "visible before" in cleaned
    assert "visible after" in cleaned
    assert "internal control text" not in cleaned
    assert "OUT-OF-BAND USER MESSAGE" not in cleaned


def test_strip_oob_blocks_multiple_in_one_string():
    """Multiple OOB blocks in a single string should all be stripped."""
    content = (
        f"before {OOB_BLOCK} middle {OOB_HYPHEN} end"
    )

    cleaned = _strip_oob_blocks(content)

    assert "before" in cleaned
    assert "middle" in cleaned
    assert "end" in cleaned
    # Count occurrences — should be zero after stripping
    assert cleaned.count("OUT-OF-BAND USER MESSAGE") == 0


def test_strip_oob_blocks_incomplete_block_preserved():
    """Incomplete/unclosed OOB blocks should NOT be stripped."""
    content = "visible before\n[OUT-OF-BAND USER MESSAGE — incomplete"

    cleaned = _strip_oob_blocks(content)

    # Incomplete block should remain untouched
    assert "[OUT-OF-BAND USER MESSAGE" in cleaned


def test_restore_metadata_aligns_row_with_empty_tool_calls():
    """#5737 third site: _restore_reasoning_metadata aligns previous vs updated
    rows by their API-safe projection. A row stored with tool_calls: [] must
    project identically on both sides (empty key dropped) so its reasoning /
    stable id / timestamp still carry forward — otherwise the projections diverge
    and the row silently loses its metadata on every turn."""
    previous = [
        {"role": "user", "content": "q"},
        {
            "role": "assistant",
            "content": "answer",
            "tool_calls": [],            # empty — the strict-provider case
            "reasoning": "prior reasoning",
            "id": "msg-42",
            "timestamp": 1234,
        },
    ]
    # The agent echoes the row back WITHOUT our metadata (and without tool_calls).
    updated = [
        {"role": "user", "content": "q"},
        {"role": "assistant", "content": "answer"},
    ]

    restored = _restore_reasoning_metadata(previous, updated)

    asst = restored[1]
    assert asst["reasoning"] == "prior reasoning", "reasoning must carry forward across the empty-tool_calls row"
    assert asst["id"] == "msg-42", "stable id must carry forward"
    assert asst["timestamp"] == 1234, "timestamp must carry forward (row must not re-mint / drift)"
