"""
Sprint 41 Tests: Title auto-generation fix (PR #333).

Covers:
- streaming.py: sessions titled 'New Chat' trigger auto-title generation
- streaming.py: sessions with empty/falsy title trigger auto-title generation
- streaming.py: sessions titled 'Untitled' (original guard) still trigger
- streaming.py: sessions with a user-set title do NOT trigger auto-title
"""
import pathlib
import re
import unittest
from types import SimpleNamespace

REPO_ROOT = pathlib.Path(__file__).parent.parent
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
STREAMING_PY = (REPO_ROOT / "api" / "streaming.py").read_text(encoding="utf-8")


# ── streaming.py: title auto-generation condition ─────────────────────────

class TestTitleAutoGenerationCondition(unittest.TestCase):
    """Verify the guarded condition in streaming.py covers all default title cases."""

    def _titles_that_trigger(self):
        """Extract the condition from the source so tests stay in sync with code."""
        # Find the if-condition that calls title_from
        m = re.search(
            r'if\s+(s\.title\s*==.*?):\s*\n\s*s\.title\s*=\s*title_from',
            STREAMING_PY,
            re.DOTALL,
        )
        self.assertIsNotNone(m, "Could not find title auto-generation condition in streaming.py")
        return m.group(1)

    def test_untitled_in_condition(self):
        cond = self._titles_that_trigger()
        self.assertIn("'Untitled'", cond, "Original 'Untitled' guard must be present")

    def test_new_chat_in_condition(self):
        cond = self._titles_that_trigger()
        self.assertIn("'New Chat'", cond, "'New Chat' guard must be present (PR #333)")

    def test_empty_title_guard_in_condition(self):
        cond = self._titles_that_trigger()
        self.assertIn("not s.title", cond, "Empty/falsy title guard must be present (PR #333)")

    def test_condition_logic_covers_all_defaults(self):
        """The condition uses OR so any one default title triggers generation."""
        cond = self._titles_that_trigger()
        # All three guards must be joined by 'or'
        parts = re.split(r'\bor\b', cond)
        self.assertGreaterEqual(len(parts), 3,
            "Expected at least 3 OR-joined sub-conditions (Untitled, New Chat, not s.title)")




class TestIssue495TitleStreaming(unittest.TestCase):
    """Regression checks for issue #495 title SSE behavior."""

    def test_streaming_has_llm_title_helper(self):
        self.assertIn(
            "def _generate_llm_session_title_for_agent(",
            STREAMING_PY,
            "streaming.py should define an agent-backed LLM title helper for session titles",
        )

    def test_streaming_rejects_generic_completion_titles(self):
        self.assertIn(
            "all set",
            STREAMING_PY,
            "streaming.py should reject generic English completion phrases as session titles",
        )
        self.assertIn(
            "completed",
            STREAMING_PY,
            "streaming.py should reject completion-status titles as session titles",
        )
        self.assertNotIn(
            "测试完成",
            STREAMING_PY,
            "streaming.py title generation should stay English-only",
        )

    def test_streaming_uses_reasoning_split_for_minimax_titles(self):
        self.assertIn(
            "reasoning_split",
            STREAMING_PY,
            "streaming.py should request MiniMax title calls with reasoning_split so final text is separated from thinking",
        )

    def test_streaming_emits_title_sse_event(self):
        # After the stream_end fix, title uses original session_id param (not s.session_id
        # which can be rotated during context compression — see #652 fix)
        self.assertIn(
            "put_event('title', {'session_id': session_id, 'title': effective_title})",
            STREAMING_PY,
            "streaming.py should emit a title SSE event when title is updated",
        )

    def test_streaming_emits_title_status_sse_event(self):
        self.assertIn(
            "put_event('title_status', payload)",
            STREAMING_PY,
            "streaming.py should emit a title_status SSE event for title generation diagnostics",
        )

    def test_streaming_emits_stream_end_event(self):
        self.assertIn(
            "put_event('stream_end', {'session_id': session_id})",
            STREAMING_PY,
            "background title path should end the SSE stream with stream_end",
        )

    def test_frontend_listens_for_title_event(self):
        self.assertIn(
            "addEventListener('title'",
            MESSAGES_JS,
            "messages.js should listen for title SSE events",
        )

    def test_frontend_listens_for_title_status_event(self):
        self.assertIn(
            "addEventListener('title_status'",
            MESSAGES_JS,
            "messages.js should listen for title_status SSE events",
        )
        self.assertIn(
            "console.info('[title]'",
            MESSAGES_JS,
            "messages.js should log title generation diagnostics to the browser console",
        )

    def test_frontend_refreshes_title_ui_after_title_event(self):
        self.assertIn(
            "syncTopbar()",
            MESSAGES_JS,
            "messages.js title listener should sync top bar title",
        )
        self.assertTrue(
            ("renderSessionListFromCache()" in MESSAGES_JS) or ("renderSessionList()" in MESSAGES_JS),
            "messages.js title listener should refresh session list UI",
        )

    def test_frontend_waits_for_stream_end_before_closing(self):
        self.assertIn(
            "addEventListener('stream_end'",
            MESSAGES_JS,
            "messages.js should close SSE connection on stream_end (not immediately on done)",
        )

    def test_title_snippet_uses_visible_assistant_reply_after_tools(self):
        """Tool-heavy opening turns should use the final visible assistant reply."""
        from api.streaming import _first_exchange_snippets

        user_msg = {
            "role": "user",
            "content": "Please look up the earlier context and then summarize it.",
        }
        preamble_asst = {
            "role": "assistant",
            "content": "Let me check my memory first.",
            "tool_calls": [
                {
                    "id": "call-1",
                    "function": {
                        "name": "memory",
                        "arguments": '{"action":"search"}',
                    },
                }
            ],
        }
        tool_result = {
            "role": "tool",
            "tool_call_id": "call-1",
            "content": '{"result":"background info"}',
        }
        final_asst = {
            "role": "assistant",
            "content": "Here is the substantive answer after the tool work.",
        }

        user_text, assistant_text = _first_exchange_snippets(
            [user_msg, preamble_asst, tool_result, final_asst]
        )

        self.assertEqual(user_text, user_msg["content"][:500])
        self.assertEqual(assistant_text, final_asst["content"][:500])

    def test_title_snippet_keeps_short_substantive_assistant_reply(self):
        """Short but real assistant answers should still be eligible for titles."""
        from api.streaming import _first_exchange_snippets

        messages = [
            {"role": "user", "content": "Can you help me rename this session?"},
            {"role": "assistant", "content": "Sure."},
        ]

        user_text, assistant_text = _first_exchange_snippets(messages)

        self.assertEqual(user_text, "Can you help me rename this session?")
        self.assertEqual(assistant_text, "Sure.")

    def test_provisional_title_detection_ignores_whitespace_noise(self):
        """Temporary first-message titles should still match with whitespace normalization."""
        from api.streaming import _is_provisional_title, title_from

        messages = [
            {
                "role": "user",
                "content": "过去两个礼拜发生了一些事情。最重要的一点就是我加入了一个 Hermes Web UI 的项目。\n\n因为我开始使用 Hermes 这个 agent 以后，就逐渐不再使用 OpenClaw了。",
            },
            {"role": "assistant", "content": "Sure, let me help."},
        ]

        derived = title_from(messages, "")
        current = derived[:64]  # Simulate the provisional title the UI writes immediately.

        self.assertTrue(
            _is_provisional_title(current, messages),
            "Whitespace-normalized provisional titles should still be recognized",
        )

    def test_structured_workspace_prefixed_provisional_title_is_eligible(self):
        """The persisted multimodal first turn remains eligible for auto-title."""
        from api.streaming import (
            _background_title_generation_inputs,
            _is_provisional_title,
            title_from,
        )

        visible_text = (
            "[Workspace::v1: user-authored]\n"
            "Describe uploads naturally.\n\n"
            "Attached files are mentioned in ordinary prose."
        )
        messages = [
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": (
                            "[Workspace::v1: /internal]\n"
                            f"{visible_text}\n\n"
                            "[Attached files: /attachments/uploads.png]"
                        ),
                    },
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,workflow"},
                    },
                ],
            },
            {"role": "assistant", "content": "Here is the completed workflow."},
        ]
        session = SimpleNamespace(
            title=title_from([{"role": "user", "content": visible_text}], ""),
            messages=messages,
            llm_title_generated=False,
        )

        self.assertEqual(session.title, visible_text[:64])
        self.assertTrue(_is_provisional_title(session.title, session.messages))
        title_inputs = _background_title_generation_inputs(session)
        expected_user_text = " ".join(visible_text.split())
        self.assertEqual(title_inputs, (expected_user_text, "Here is the completed workflow."))
        user_text, _ = title_inputs
        self.assertIn("Attached files are mentioned in ordinary prose.", user_text)
        self.assertNotIn("[Attached files: /attachments/uploads.png]", user_text)
        for forbidden in ("/internal",):
            self.assertNotIn(forbidden, user_text)

    def test_structured_title_input_skips_empty_and_image_only_users(self):
        """The first title-bearing user row wins over empty/image-only rows."""
        from api.streaming import (
            _background_title_generation_inputs,
            _is_provisional_title,
        )

        visible_text = (
            "Describe the three-step workflow with enough detail to exercise title truncation "
            "after skipping empty image-only rows."
        )
        long_workspace = "/workspace/" + ("long-title-sentinel-" * 4)
        messages = [
            {"role": "user", "content": None},
            {
                "role": "user",
                "content": [
                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,empty"}}
                ],
            },
            {
                "role": "user",
                "content": (
                    "[Workspace::v1: /internal-only-title-metadata]\n\n"
                    "[Attached files: /attachments/internal-only.png]"
                ),
            },
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": (
                            "[Workspace::v1: /internal-only-title-part]\n\n"
                            "[Attached files: /attachments/internal-only-part.png]"
                        ),
                    },
                    {
                        "type": "text",
                        "text": (
                            f"[Workspace::v1: {long_workspace}]\n{visible_text}\n\n"
                            "[Attached files: /attachments/workflow.png]"
                        ),
                    },
                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,workflow"}},
                ],
            },
            {"role": "assistant", "content": "The workflow is complete."},
        ]
        session = SimpleNamespace(
            title=visible_text[:64], messages=messages, llm_title_generated=False
        )

        self.assertGreater(len(long_workspace), 64)
        self.assertTrue(_is_provisional_title(session.title, session.messages))
        self.assertEqual(
            _background_title_generation_inputs(session),
            (visible_text, "The workflow is complete."),
        )

    def test_input_text_title_snippet_remains_background_eligible(self):
        """Mixed structured input_text content remains a title input."""
        from api.streaming import (
            _background_title_generation_inputs,
            _first_exchange_snippets,
            _is_provisional_title,
        )

        visible_text = "Describe the input-text workflow."
        assistant_text = "The workflow is complete."
        messages = [
            {
                "role": "user",
                "content": [{"type": "input_text", "text": visible_text}],
            },
            {"role": "assistant", "content": assistant_text},
        ]
        session = SimpleNamespace(
            title="Untitled", messages=messages, llm_title_generated=False
        )

        self.assertEqual(
            _first_exchange_snippets(messages),
            (visible_text, assistant_text),
        )
        self.assertEqual(
            _background_title_generation_inputs(session),
            (visible_text, assistant_text),
        )
        self.assertFalse(
            _is_provisional_title(
                "Alias text",
                [{"role": "user", "content": [{"type": "text", "input_text": "Alias text"}]}],
            )
        )

    def test_later_structured_text_part_remains_literal(self):
        """Only the leading structured text part is metadata-normalized."""
        from api.streaming import (
            _background_title_generation_inputs,
            _is_provisional_title,
            title_from,
        )

        first_text = "Describe the literal multi-part request."
        later_text = (
            "[Workspace::v1: /literal-user-text] must remain literal user content.\n\n"
            "[Attached files: literal.txt]"
        )
        messages = [
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": (
                            "[Workspace::v1: /workspace/example]\n"
                            f"{first_text}\n\n"
                            "[Attached files: /attachments/workflow.png]"
                        ),
                    },
                    {"type": "text", "text": later_text},
                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,literal"}},
                ],
            },
            {"role": "assistant", "content": "The literal content is preserved."},
        ]
        sanitized_title_text = f"{first_text} {later_text}"
        expected_title_input = " ".join(sanitized_title_text.split())
        session = SimpleNamespace(
            title=title_from([{"role": "user", "content": sanitized_title_text}], ""),
            messages=messages,
            llm_title_generated=False,
        )

        self.assertTrue(_is_provisional_title(session.title, session.messages))
        title_inputs = _background_title_generation_inputs(session)
        self.assertEqual(
            title_inputs,
            (expected_title_input, "The literal content is preserved."),
        )
        user_text, _ = title_inputs
        for forbidden in ("/workspace/example", "/attachments/workflow.png"):
            self.assertNotIn(forbidden, user_text)
        self.assertIn(" ".join(later_text.split()), user_text)
        self.assertIn("[Attached files: literal.txt]", user_text)

    def test_latest_structured_title_input_is_sanitized(self):
        """Latest title-refresh input strips workspace and attachment metadata."""
        from api.streaming import _latest_exchange_snippets

        visible_text = "Summarize the latest workflow."
        assistant_text = "Here is the refreshed summary."
        messages = [
            {"role": "user", "content": "Earlier request."},
            {"role": "assistant", "content": "Earlier answer."},
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": (
                            "[Workspace::v1: /workspace/latest]\n"
                            f"{visible_text}\n\n"
                            "[Attached files: /attachments/latest.png]"
                        ),
                    },
                    {"type": "image_url", "image_url": {"url": "data:image/png;base64:latest"}},
                ],
            },
            {"role": "assistant", "content": assistant_text},
        ]

        user_text, returned_assistant_text = _latest_exchange_snippets(messages)

        self.assertEqual(user_text, visible_text)
        self.assertEqual(returned_assistant_text, assistant_text)
        self.assertNotIn("/workspace/latest", user_text)
        self.assertNotIn("/attachments/latest.png", user_text)

        metadata_only_messages = [
            {"role": "user", "content": "Earlier request."},
            {"role": "assistant", "content": "Earlier answer."},
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": (
                            "[Workspace::v1: /workspace/latest]\n\n"
                            "[Attached files: /attachments/latest.png]"
                        ),
                    },
                    {"type": "image_url", "image_url": {"url": "data:image/png;base64:latest"}},
                ],
            },
            {"role": "assistant", "content": "Latest answer."},
        ]
        self.assertEqual(_latest_exchange_snippets(metadata_only_messages), ("", ""))

    def test_literal_legacy_workspace_text_is_preserved_in_title_inputs(self):
        """Legacy workspace text remains user content rather than metadata."""
        from api.streaming import (
            _background_title_generation_inputs,
            _is_provisional_title,
            title_from,
        )

        messages = [
            {"role": "user", "content": "[Workspace: /literal-user-text]\nExplain this literal prefix."},
            {"role": "assistant", "content": "The legacy prefix is literal."},
        ]
        session = SimpleNamespace(
            title=title_from(messages, ""),
            messages=messages,
            llm_title_generated=False,
        )

        self.assertTrue(_is_provisional_title(session.title, session.messages))
        user_text, _ = _background_title_generation_inputs(session)
        self.assertIn("[Workspace: /literal-user-text]", user_text)

    def test_title_snippet_keeps_tool_call_with_substantive_text(self):
        """An assistant row with tool_calls AND a substantive answer text
        must still be used as the first-exchange snippet — it's not a
        preamble, it's an agentic first-turn plan."""
        from api.streaming import _first_exchange_snippets

        user_msg = {
            "role": "user",
            "content": "Can you schedule a reminder for the Q3 kickoff meeting?",
        }
        # Assistant row with both a real answer AND a tool_call
        agentic_asst = {
            "role": "assistant",
            "content": "I'll schedule the Q3 kickoff reminder for next Monday at 9am.",
            "tool_calls": [
                {
                    "id": "call-1",
                    "function": {
                        "name": "cronjob",
                        "arguments": '{"action":"create","when":"mon 9am"}',
                    },
                }
            ],
        }

        user_text, assistant_text = _first_exchange_snippets([user_msg, agentic_asst])

        self.assertEqual(user_text, user_msg["content"][:500])
        self.assertEqual(
            assistant_text,
            agentic_asst["content"][:500],
            "Substantive answer text on a tool_call row must be preserved",
        )

    def test_fallback_title_preserves_unicode_letters(self):
        """Local fallback title generation must not strip German umlauts."""
        from api.streaming import _fallback_title_from_exchange

        title = _fallback_title_from_exchange(
            "Bitte führe ein Selbst-Audit durch. Wo ist überall noch Gemini-2.5-flash als Modell im Einsatz? Sei gründlich",
            "Ich prüfe live statt aus Bauchgefühl.",
        )

        self.assertIsNotNone(title)
        self.assertIn("führe", title)
        self.assertNotIn("hre", title.split())

    def test_title_snippet_skips_tool_call_preamble_only_rows(self):
        """Tool-call rows whose content is empty or meta-reasoning preamble
        ('Let me check my memory first.') must still be skipped — those are
        orchestration scaffolding, not title material."""
        from api.streaming import _first_exchange_snippets

        user_msg = {
            "role": "user",
            "content": "Summarize my notes from last week.",
        }
        empty_preamble = {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "call-1",
                    "function": {
                        "name": "memory",
                        "arguments": '{"action":"search"}',
                    },
                }
            ],
        }
        meta_preamble = {
            "role": "assistant",
            "content": "Let me check my memory first.",
            "tool_calls": [
                {
                    "id": "call-2",
                    "function": {
                        "name": "memory",
                        "arguments": '{"action":"search","q":"last week"}',
                    },
                }
            ],
        }
        tool_result = {
            "role": "tool",
            "tool_call_id": "call-2",
            "content": '{"result":"background info"}',
        }
        final_asst = {
            "role": "assistant",
            "content": "Here's a summary of your notes from last week.",
        }

        _, assistant_text = _first_exchange_snippets(
            [user_msg, empty_preamble, meta_preamble, tool_result, final_asst]
        )

        self.assertEqual(
            assistant_text,
            final_asst["content"][:500],
            "Empty and meta-reasoning preamble rows must be skipped",
        )


if __name__ == "__main__":
    unittest.main()
