"""Regression locks: SSE-recovery / streaming scroll-stranding fixes.

Two distinct "jump back" classes, both verified live this session:

1. Content-grew-beneath-a-pinned-viewport (static/ui.js scroll listener):
   while streaming on a tall transcript, new content increases scrollHeight under
   a stationary viewport. The reader never scrolled (top did not move up,
   _messageUserUnpinned is false), but bottomDistance crosses the nearBottom
   threshold, so the old code fell through to `_scrollPinned=false`, killing
   auto-follow mid-stream. The follow writer and the scroll listener then fought
   frame-by-frame; the viewport stalled while content kept growing and was
   progressively stranded mid-transcript. Fix: in the `!_messageUserUnpinned`
   branch, when the viewport did NOT move up and auto-follow is on, keep the pin
   and re-snap to the true bottom instead of unpinning.

2. SSE-recovery follow-restore (static/messages.js): _handleStreamError (SSE
   drop), the Task-cancelled apply/fallback paths, and the reconnect-stream-dead
   cleanup all push/replace S.messages then renderMessages({preserveScroll:true}).
   preserveScroll's restore path keys on the pre-render snapshot's bottom-distance,
   which during a live stream can read large (content grew under a followed
   viewport), so it yanked a following reader up to a stale historical position on
   a process restart / SSE drop / cancel. Fix: capture follow-intent
   (_isMessagePaneNearBottom) BEFORE mutating S.messages, and after the recovery
   render, scrollToBottom() if the reader was following — so they see the
   interruption/cancellation notice in place instead of being thrown back into the
   transcript. Readers who had scrolled up to read history are left where they were.

These are structural source-locks (the behavioral A/B was verified live via
Playwright: OLD stranded the reader 470-580px from bottom, FIX landed at 1px).
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")


def _compact(s: str) -> str:
    return "".join(s.split())


def test_content_grew_keeps_pin_and_resnaps_not_unpin():
    # The scroll listener's !_messageUserUnpinned branch must, when the viewport
    # did NOT move up and auto-follow is on, keep the pin and re-snap to bottom
    # rather than unpinning. Assert the new guard + re-snap call are present.
    c = _compact(UI_JS)
    assert _compact("}else if(!movedUp && window._autoScrollFollow && _scrollPinned){") in c, (
        "content-grew-beneath-pinned guard missing from the scroll listener"
    )
    # The guard body must re-snap to bottom, not set _scrollPinned=false.
    idx = c.find(_compact("else if(!movedUp && window._autoScrollFollow && _scrollPinned){"))
    assert idx != -1
    # The re-snap call must be the FIRST scroll action after the guard opens,
    # before the chain reaches any `else{ _scrollPinned=false }` fallthrough.
    after = c[idx:]
    resnap = after.find(_compact("_setMessageScrollToBottom()"))
    fallthrough = after.find(_compact("}else{_nearBottomCount=0;_scrollPinned=false;}"))
    assert resnap != -1, "content-grew guard must re-snap via _setMessageScrollToBottom()"
    assert fallthrough != -1, "the original unpin fallthrough should still exist for the real scroll-away case"
    assert resnap < fallthrough, (
        "the re-snap must be inside the content-grew guard body, BEFORE the unpin fallthrough"
    )


def test_sse_recovery_paths_capture_follow_intent_and_refollow():
    # All four SSE-recovery mutation points must capture follow-intent before
    # mutating S.messages and re-follow after the recovery render.
    c = _compact(MESSAGES_JS)
    for guard in (
        "_wasFollowingAtDisconnect",      # _handleStreamError (SSE drop)
        "_wasFollowingAtCancel",          # Task cancelled (embedded payload)
        "_wasFollowingAtCancelFb",        # Task cancelled (fallback)
        "_wasFollowingAtReconnectDead",   # reconnect-stream-dead cleanup
    ):
        assert guard in MESSAGES_JS, f"SSE-recovery follow-intent guard '{guard}' missing"
        # each guard is computed via _isMessagePaneNearBottom AND a sticky-unpin
        # check, then consumed by a scrollToBottom() re-follow.
        assert _compact("_isMessagePaneNearBottom(1200)") in c, (
            "follow-intent must be computed via _isMessagePaneNearBottom(1200)"
        )
        # STICKY-FOLLOW INVARIANT (maintainer must-fix): proximity alone is NOT
        # enough — a reader who manually scrolled up but stayed within 1200px sets
        # _messageUserUnpinned and must NOT be re-followed on recovery. Each guard
        # must AND in the sticky-unpin state via _isMessageReaderUnpinned (with a
        # _messageUserUnpinned fallback). Assert the guard is gated on NOT-unpinned.
        assert _compact("_isMessageReaderUnpinned") in c, (
            "follow-intent must consult the sticky _isMessageReaderUnpinned state"
        )
        assert _compact("_messageUserUnpinned") in c, (
            "follow-intent must fall back to _messageUserUnpinned when the helper is absent"
        )
    # Each guard must drive a scrollToBottom re-follow.
    for guard in ("_wasFollowingAtDisconnect", "_wasFollowingAtCancel",
                  "_wasFollowingAtCancelFb", "_wasFollowingAtReconnectDead"):
        assert _compact(f"if({guard} && typeof scrollToBottom==='function') scrollToBottom()") in c, (
            f"guard '{guard}' must re-follow via scrollToBottom() when the reader was following"
        )


def test_follow_intent_captured_before_mutation():
    # Ordering invariant: inside _handleStreamError, the _wasFollowingAtDisconnect
    # capture must appear BEFORE the terminal-error marker is inserted into
    # S.messages (capturing after the mutation would read a post-insert
    # bottom-distance and defeat the fix). Scope the search to the
    # _handleStreamError function body — messages.js has more than one
    # "Connection interrupted" push and more than one _handleStreamError
    # reference, so a global str.find() would compare across unrelated paths.
    src = MESSAGES_JS
    fn = src.find("function _handleStreamError")
    assert fn != -1, "could not locate _handleStreamError"
    body = src[fn:fn + 8000]
    cap = body.find("_wasFollowingAtDisconnect=")
    mutation = body.find("_ensureSingleTerminalStreamErrorMarker(S.messages)")
    assert cap != -1, "follow-intent capture not found in _handleStreamError"
    assert mutation != -1, "terminal-error marker insertion not found in _handleStreamError"
    assert cap < mutation, (
        "follow-intent must be captured BEFORE the terminal-error marker is "
        "inserted into S.messages"
    )


@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
def test_sticky_follow_invariant_unpinned_within_1200_is_not_refollowed():
    """Behavioral: the sticky-aware guard re-follows a genuine follower but spares
    a reader who manually scrolled up (unpinned) even while still within 1200px.

    Extracts the real _wasFollowingAtDisconnect expression from messages.js and
    evaluates it in Node under the four (nearBottom x unpinned) states, stubbing
    _isMessagePaneNearBottom / _isMessageReaderUnpinned to the scenario values.
    """
    src = MESSAGES_JS
    start = src.index("const _wasFollowingAtDisconnect=")
    # capture through the terminating semicolon of the const declaration
    end = src.index(";", src.index("_messageUserUnpinned", start))
    expr = src[start:end + 1]

    def run(near_bottom: bool, unpinned: bool) -> bool:
        harness = textwrap.dedent(f"""
            let _messageUserUnpinned = {str(unpinned).lower()};
            function _isMessagePaneNearBottom(px){{ return {str(near_bottom).lower()}; }}
            function _isMessageReaderUnpinned(){{ return {str(unpinned).lower()}; }}
            {expr}
            console.log(JSON.stringify(_wasFollowingAtDisconnect));
        """)
        res = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=30)
        assert res.returncode == 0, res.stderr
        return json.loads(res.stdout.strip())

    # Genuine follower (pinned, at/near bottom) → re-follow.
    assert run(near_bottom=True, unpinned=False) is True
    # MAINTAINER MUST-FIX: manually-unpinned reader STILL within 1200px → NOT re-followed.
    assert run(near_bottom=True, unpinned=True) is False, (
        "a reader who scrolled up (unpinned) within 1200px must NOT be re-followed on recovery"
    )
    # Scrolled far away (not near bottom), pinned flag irrelevant → not re-followed.
    assert run(near_bottom=False, unpinned=False) is False
    assert run(near_bottom=False, unpinned=True) is False
