"""Static UI tests for quieter tool-call rendering and shared design tokens.

These tests intentionally follow the repo's existing pytest style: read static
source files, isolate the relevant function/rule, and assert implementation
invariants before changing the UI.
"""
import pathlib
import re

REPO = pathlib.Path(__file__).parent.parent
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")


def _function_body(src: str, name: str) -> str:
    match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
    assert match, f"{name}() not found"
    brace = src.find("{", match.end())
    assert brace != -1, f"{name}() has no body"
    depth = 1
    i = brace + 1
    in_string = None
    escaped = False
    in_line_comment = False
    in_block_comment = False
    while i < len(src) and depth:
        ch = src[i]
        nxt = src[i + 1] if i + 1 < len(src) else ""
        if in_line_comment:
            if ch == "\n":
                in_line_comment = False
            i += 1
            continue
        if in_block_comment:
            if ch == "*" and nxt == "/":
                in_block_comment = False
                i += 2
                continue
            i += 1
            continue
        if in_string:
            if escaped:
                escaped = False
            elif ch == "\\":
                escaped = True
            elif ch == in_string:
                in_string = None
            i += 1
            continue
        if ch == "/" and nxt == "/":
            in_line_comment = True
            i += 2
            continue
        if ch == "/" and nxt == "*":
            in_block_comment = True
            i += 2
            continue
        if ch in "'\"`":
            in_string = ch
            i += 1
            continue
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
        i += 1
    assert depth == 0, f"{name}() body did not close"
    return src[brace + 1:i - 1]


class TestToolCallGroupingStatic:
    def test_simplified_tool_calling_setting_is_wired_through_frontend(self):
        assert "settingsSimplifiedToolCalling" in (REPO / "static" / "index.html").read_text(encoding="utf-8"), (
            "Settings should expose a Compact tool activity checkbox."
        )
        assert "window._simplifiedToolCalling" in (REPO / "static" / "boot.js").read_text(encoding="utf-8"), (
            "Boot should hydrate simplified_tool_calling into a runtime flag."
        )
        panels = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
        assert "settingsSimplifiedToolCalling" in panels and "simplified_tool_calling" in panels, (
            "Settings panel should load and save the simplified_tool_calling setting."
        )

    def test_simplified_tool_calling_autosave_hot_applies_renderer_mode(self):
        panels = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
        fn = _function_body(panels, "_autosavePreferencesSettings")
        assert "window._simplifiedToolCalling" in fn, (
            "Autosaving Compact tool activity should update the live renderer flag immediately."
        )
        assert "clearMessageRenderCache()" in fn, (
            "Autosaving Compact tool activity should invalidate cached transcript HTML."
        )
        assert "renderMessages()" in fn, (
            "Autosaving Compact tool activity should rebuild the visible transcript without a refresh."
        )

    def test_render_messages_gates_settled_activity_grouping(self):
        fn = _function_body(UI_JS, "renderMessages")
        helper = _function_body(UI_JS, "ensureActivityGroup")
        assert "isSimplifiedToolCalling()" in fn, (
            "Settled compact inline activity rendering should be gated by the Compact tool activity toggle."
        )
        assert "tool-cards-toggle" in fn, (
            "The non-simplified path should preserve the upstream loose tool-card controls."
        )
        assert "data-tool-call-group" in helper, (
            "Tool-call groups need a stable data-tool-call-group attribute for CSS and tests."
        )
        assert re.search(r"cards\.length|toolCount|toolCalls\.length|group\.length", fn + helper), (
            "The simplified group header should derive its summary/count from the number of tool calls."
        )

    def test_tool_call_groups_default_collapsed_with_summary_visible(self):
        fn = _function_body(UI_JS, "renderMessages")
        helper = _function_body(UI_JS, "ensureActivityGroup")
        assert "tool-call-group-collapsed" in fn or "collapsed" in fn, (
            "Historical tool-call groups should default to a collapsed state."
        )
        assert "tool-call-group-summary" in helper, (
            "Collapsed groups must expose a visible summary/header row."
        )
        assert "tool-call-group-body" in helper, (
            "Tool-card detail rows should live inside a group body that can be "
            "expanded/collapsed."
        )
        assert "aria-expanded" in helper, (
            "The expand/collapse control must expose aria-expanded."
        )

    def test_activity_summary_omits_redundant_trailing_count_badge(self):
        helper = _function_body(UI_JS, "ensureActivityGroup")
        sync_fn = _function_body(UI_JS, "_syncToolCallGroupSummary")
        assert "tool-call-group-count" not in helper, (
            "Compact Activity summaries already state tool counts in the label; "
            "do not render a second trailing count badge."
        )
        assert "tool-call-group-count" not in sync_fn, (
            "The summary sync path should not update a hidden/removed trailing count badge."
        )

    def test_activity_summary_keeps_header_compact_without_tool_names_or_thinking_prefix(self):
        helper = _function_body(UI_JS, "ensureActivityGroup")
        sync_fn = _function_body(UI_JS, "_syncToolCallGroupSummary")
        assert "tool-call-group-list" not in helper, (
            "The compact Activity row should not allocate a secondary tool-name/thinking summary span."
        )
        assert "tool-call-group-list" not in sync_fn, (
            "The summary sync path should not populate a redundant tool-name/thinking list."
        )
        assert "Activity: thinking +" not in sync_fn, (
            "When tools are present, thinking is expected and should not be repeated in the label."
        )

    def test_live_tool_cards_use_grouping_only_when_simplified(self):
        live_fn = _function_body(UI_JS, "appendLiveToolCard")
        settled_fn = _function_body(UI_JS, "renderMessages")
        assert "isSimplifiedToolCalling()" in live_fn, (
            "Live streaming tool cards should branch on the Compact tool activity timeline mode."
        )
        assert "ensureActivityGroup" in live_fn, (
            "Compact live tool rendering should use the grouped activity container."
        )
        assert "toolRunningRow" in live_fn, (
            "The non-simplified live tool path should preserve the upstream running-dots row."
        )
        assert "buildToolCard" in live_fn and "buildToolCard" in settled_fn, (
            "Live and settled tool rendering should share buildToolCard() for consistent markup."
        )
        assert "data-live-tid" in live_fn, (
            "Live grouping must preserve data-live-tid so tool_start/tool_complete updates still replace the correct card."
        )

    def test_activity_disclosure_state_is_session_and_turn_scoped(self):
        helper = _function_body(UI_JS, "ensureActivityGroup")
        toggle_fn = _function_body(UI_JS, "_toggleActivityGroup")
        key_fn = _function_body(UI_JS, "_activityDisclosureStorageKey")
        render_fn = _function_body(UI_JS, "renderMessages")
        live_fn = _function_body(UI_JS, "appendLiveToolCard")
        thinking_fn = _function_body(UI_JS, "appendThinking")
        done_fn = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
        assert "hermes-activity-disclosure:" in UI_JS, (
            "Activity disclosure state should use a dedicated localStorage namespace."
        )
        assert "S.session.session_id" in key_fn, (
            "Activity disclosure state must be scoped to the current chat/session."
        )
        assert "data-activity-disclosure-key" in helper, (
            "Each Activity group needs a stable per-turn key for persisted disclosure state."
        )
        assert "_readActivityDisclosureState" in helper, (
            "ensureActivityGroup() should hydrate the saved open/closed state before using defaults."
        )
        assert "_writeActivityDisclosureState" in toggle_fn, (
            "Clicking the Activity summary should persist the new open/closed state."
        )
        assert "assistant:" in render_fn, (
            "Settled Activity groups should be keyed by assistant message index."
        )
        assert "live:" in live_fn + thinking_fn, (
            "Live Activity groups should be keyed by active stream id."
        )
        assert "_copyActivityDisclosureState('live:'+streamId, 'assistant:'" in done_fn, (
            "When a live turn settles, its saved disclosure state should transfer to the persisted assistant turn."
        )

    def test_live_tool_activity_defaults_collapsed_unless_saved_open(self):
        live_fn = _function_body(UI_JS, "appendLiveToolCard")
        helper = _function_body(UI_JS, "ensureActivityGroup")
        assert "collapsed:false" not in re.sub(r"\s+", "", live_fn), (
            "Compact live tool activity should not force-open every time a chat is revisited."
        )
        assert "savedState==='open'" in helper or 'savedState==="open"' in helper, (
            "A previously-open Activity group should still restore open from persisted state."
        )

    def test_live_activity_summary_shows_readable_progress_without_persisted_content(self):
        sync_fn = _function_body(UI_JS, "_syncToolCallGroupSummary")
        progress_fn = _function_body(UI_JS, "_activityProgressLabelForToolName")
        live_progress_fn = _function_body(UI_JS, "_activityLiveProgressLabel")
        assert "_activityLiveProgressLabel" in sync_fn, (
            "Live compact Activity rows should expose a readable transient progress label."
        )
        assert "durationEl.textContent" in sync_fn and "filter(Boolean).join(' · ')" in sync_fn, (
            "Progress should share the existing non-persistent summary/duration slot, not become transcript text."
        )
        for label in ("Searching workspace", "Reading files", "Updating files", "Running command"):
            assert label in progress_fn
        assert "tool-card-running" in live_progress_fn, (
            "The live progress label should prefer the currently running tool over older completed tools."
        )
        assert "tool-call-group-list" not in sync_fn, (
            "Readable progress must not reintroduce the noisy secondary tool-name list."
        )

    def test_live_thinking_suppresses_visible_interim_echoes(self):
        interim_match = re.search(r"source\.addEventListener\('interim_assistant',e=>\{(.*?)\n\s*\}\);", MESSAGES_JS, re.S)
        assert interim_match, "interim_assistant listener not found"
        interim_fn = interim_match.group(1)
        live_thinking_fn = _function_body(MESSAGES_JS, "_liveThinkingText")

        assert "visibleInterimSnippets.push(visible)" in interim_fn, (
            "Visible interim commentary should be remembered so the live Thinking card does not echo it."
        )
        assert "_stripLiveVisibleAssistantEchoFromThinking" in live_thinking_fn, (
            "Live Thinking text should suppress exact visible interim commentary echoes."
        )

    def test_settled_thinking_suppresses_visible_assistant_echoes(self):
        render_fn = _function_body(UI_JS, "renderMessages")
        helper = _function_body(UI_JS, "_stripVisibleAssistantEchoFromThinking")
        assert "_stripVisibleAssistantEchoFromThinking(thinkingText, displayContent)" in render_fn, (
            "Settled Thinking cards should not repeat text already rendered as visible assistant content."
        )
        assert "s.length>=20" in helper, (
            "Thinking echo suppression should ignore tiny snippets to avoid over-stripping reasoning."
        )
        assert "out.split(snippet).join('')" in helper, (
            "Thinking echo suppression should remove exact visible assistant snippets from reasoning display."
        )

    def test_compact_activity_keeps_thinking_cards_after_session_switch(self):
        ui_min = re.sub(r"\s+", "", UI_JS)
        assert "functionensureActivityGroup(" in ui_min, (
            "Tool calls should still use the shared compact Activity disclosure helper."
        )
        assert "data-agent-activity-group" in UI_JS, (
            "The Activity disclosure needs a stable data-agent-activity-group hook."
        )
        render_fn = _function_body(UI_JS, "renderMessages")
        assert "isSimplifiedToolCalling()" in render_fn and "assistantThinking.set(rawIdx, thinkingText)" in render_fn, (
            "Compact settled transcript rendering should preserve Thinking cards after switching sessions."
        )
        assert "_thinkingActivityNode(thinkingText, false)" in render_fn, (
            "Settled Thinking cards should render inside the compact Activity disclosure."
        )
        assert "body.appendChild(_thinkingActivityNode(thinkingText, false))" in render_fn, (
            "Settled Thinking cards should stay inside the same Activity body as the related tools."
        )
        assert ".agent-activity-thinking:not([data-live-thinking=\"1\"])" in render_fn, (
            "Settled rerenders must remove previously inserted Thinking activity rows before rebuilding."
        )
        assert "seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText))" in render_fn, (
            "The non-simplified path should preserve standalone settled thinking cards."
        )

    def test_live_visible_interim_text_keeps_single_activity_group(self):
        live_thinking_fn = _function_body(UI_JS, "appendThinking")
        live_tool_fn = _function_body(UI_JS, "appendLiveToolCard")
        helper = _function_body(UI_JS, "ensureActivityGroup")
        assert "isSimplifiedToolCalling()" in live_thinking_fn, (
            "Live thinking should branch on the Compact tool activity toggle."
        )
        assert "_thinkingActivityNode(thinkingText, false)" in live_thinking_fn, (
            "Compact live thinking should render inside the Activity disclosure."
        )
        assert "ensureActivityGroup(blocks,{live:true" in live_thinking_fn and "body.appendChild(row)" in live_thinking_fn, (
            "Compact live thinking should share the same Activity body as live tool cards."
        )
        assert "removeAttribute('data-live-activity-current')" not in live_thinking_fn, (
            "Reasoning/Thinking updates alone should not split consecutive tools into one-tool Activity rows."
        )
        assert '.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]' in helper, (
            "Live tool cards should only reuse the current Activity burst, not the first group in the turn."
        )
        assert "group.setAttribute('data-live-activity-current','1')" in helper, (
            "New live Activity bursts must be marked current so later tools append to the right group."
        )
        assert "body.querySelector" in live_tool_fn and "data-live-tid" in live_tool_fn, (
            "tool_complete must still update its current live Activity burst by tool id."
        )
        finalize_fn = _function_body(UI_JS, "finalizeThinkingCard")
        assert "turn.querySelector('.agent-activity-thinking[data-thinking-active=\"1\"]')" in finalize_fn, (
            "Compact Thinking cards live inside the assistant turn, so finalization must clear the active marker from the whole turn."
        )
        assert "body.querySelector('.agent-activity-thinking[data-thinking-active=\"1\"]')" in live_thinking_fn and "setAttribute('data-thinking-active','1')" in live_thinking_fn, (
            "Compact live thinking should reactivate the latest existing Thinking card instead of stacking a new card after every tool boundary."
        )
        reset_fn = _function_body(MESSAGES_JS, "_resetAssistantSegment")
        assert "_closeCurrentLiveActivityGroup" not in MESSAGES_JS and "closeActivity" not in reset_fn, (
            "Assistant text resets should not carry a dead Activity-splitting path."
        )
        interim_match = re.search(r"source\.addEventListener\('interim_assistant',e=>\{(.*?)\n\s*\}\);", MESSAGES_JS, re.S)
        assert interim_match and "_resetAssistantSegment({closeActivity:true});" not in interim_match.group(1), (
            "Visible interim assistant text should not split Compact tool activity into multiple Activity rows."
        )
        tool_start_segment = MESSAGES_JS.split("source.addEventListener('tool',e=>{", 1)[1].split("source.addEventListener('tool_complete'", 1)[0]
        assert "_resetAssistantSegment();" in tool_start_segment, (
            "Tool starts should reset the next assistant text segment without closing the current Activity burst."
        )
        assert "_resetAssistantSegment({closeActivity:true});" not in tool_start_segment, (
            "Tool starts must not split consecutive tools into one-tool Activity rows."
        )

    def test_live_compression_card_splits_current_tool_activity_burst(self):
        compression_fn = _function_body(UI_JS, "appendLiveCompressionCard")
        close_fn = _function_body(UI_JS, "closeCurrentLiveActivityGroup")
        assert "closeCurrentLiveActivityGroup();" in compression_fn, (
            "Auto-compression cards should close the current live Activity burst so later tools start a fresh group."
        )
        assert "data-live-activity-current" in close_fn, (
            "The live compression boundary helper must clear the current Activity marker."
        )
        assert "removeAttribute('data-live-activity-current')" in close_fn, (
            "Closing a live Activity burst should leave the row rendered but stop later tools from reusing it."
        )


class TestToolCardDesignTokens:
    def test_root_defines_shared_layout_design_tokens(self):
        for token in (
            "--radius-sm",
            "--radius-md",
            "--radius-card",
            "--space-1",
            "--space-2",
            "--space-3",
            "--font-size-xs",
            "--font-size-sm",
            "--surface-subtle",
            "--border-subtle",
        ):
            assert token in CSS, f"Missing design token {token} in style.css"

    def test_base_dark_palette_restores_upstream_gold_tokens(self):
        css_min = re.sub(r"\s+", "", CSS)
        expected_tokens = (
            "--bg:#0D0D1A",
            "--sidebar:#141425",
            "--border:#2A2A45",
            "--text:#FFF8DC",
            "--muted:#C0C0C0",
            "--accent:#FFD700",
            "--surface:#1A1A2E",
            "--topbar-bg:rgba(20,20,37,.98)",
        )
        for token in expected_tokens:
            assert token in css_min, f"Base dark palette token missing: {token}"

    def test_base_light_palette_restores_upstream_gold_tokens(self):
        css_min = re.sub(r"\s+", "", CSS)
        expected_tokens = (
            "--bg:#FEFCF7",
            "--sidebar:#FAF7F0",
            "--border:#E0D8C8",
            "--text:#1A1610",
            "--muted:#5C5344",
            "--accent:#B8860B",
            "--surface:#F3EEE3",
        )
        for token in expected_tokens:
            assert token in css_min, f"Base light palette token missing: {token}"

    def test_default_skin_preview_stays_upstream(self):
        boot_min = re.sub(r"\s+", "", BOOT_JS)
        assert "{name:'Default',colors:['#FFD700','#FFBF00','#CD7F32']}" in boot_min, (
            "The Default skin swatch should stay aligned with the upstream gold base."
        )

    def test_tool_card_css_uses_design_tokens_for_chrome(self):
        css_min = re.sub(r"\s+", "", CSS)
        assert ".tool-card{" in css_min, ".tool-card rule missing"
        assert "border-radius:var(--radius-card)" in css_min, (
            ".tool-card border radius should use --radius-card, not hardcoded px."
        )
        assert "background:var(--surface-subtle)" in css_min, (
            ".tool-card background should use --surface-subtle."
        )
        assert "border:1pxsolidvar(--border-subtle)" in css_min, (
            ".tool-card border should use --border-subtle."
        )

    def test_tool_card_header_and_text_use_spacing_and_font_tokens(self):
        css_min = re.sub(r"\s+", "", CSS)
        assert ".tool-card-header{" in css_min, ".tool-card-header rule missing"
        assert "gap:var(--space-2)" in css_min, (
            ".tool-card-header gap should use --space-2."
        )
        assert "padding:var(--space-1)var(--space-3)" in css_min, (
            ".tool-card-header padding should use spacing tokens."
        )
        assert ".tool-card-name{" in css_min and "font-size:var(--font-size-xs)" in css_min, (
            ".tool-card-name should use --font-size-xs."
        )
        assert ".tool-card-preview{" in css_min and "font-size:var(--font-size-xs)" in css_min, (
            ".tool-card-preview should use --font-size-xs."
        )
