"""Regression tests for #1731: small upward scrolls during streaming.

The pre-fix scroll listener applied hysteresis symmetrically: an upward
scroll that landed inside the 250px near-bottom zone still reported
``nearBottom = true``, so ``_nearBottomCount`` kept incrementing and
``_scrollPinned`` stayed true. The next streaming token then snapped
the user back to the bottom. The user effectively had to escape the
250px zone in a single fling to get unpinned.

The fix tracks ``_lastScrollTop`` and unpins immediately when the user
explicitly scrolls upward, bypassing the hysteresis counter for the
unpin path while preserving it for the re-pin path (which is what the
#1360 macOS momentum protection actually needs).
"""

from pathlib import Path

REPO = Path(__file__).resolve().parents[1]
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")


def _scroll_listener_block() -> str:
    """Return the rAF callback inside the messages scroll listener."""
    anchor = "el.addEventListener('scroll'"
    start = UI_JS.index(anchor)
    raf_start = UI_JS.index("requestAnimationFrame", start)
    brace = UI_JS.index("{", raf_start)
    depth = 0
    for i in range(brace, len(UI_JS)):
        ch = UI_JS[i]
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return UI_JS[brace : i + 1]
    raise AssertionError("scroll listener rAF callback not found")


def test_scroll_listener_tracks_last_scroll_top():
    """The listener must remember the previous scrollTop to detect direction."""
    assert "let _lastScrollTop=" in UI_JS, (
        "Direction detection requires a closure-scoped _lastScrollTop "
        "tracker (#1731)."
    )

    block = _scroll_listener_block()
    assert "_lastScrollTop=top" in block, (
        "The rAF callback must update _lastScrollTop after each sample so "
        "the next sample can compare against it (#1731)."
    )


def test_scroll_listener_detects_upward_motion():
    """An upward scroll (scrollTop decreased) must be detected explicitly."""
    block = _scroll_listener_block()
    assert "movedUp" in block, (
        "The rAF callback must compute a movedUp flag from scrollTop "
        "direction so explicit upward scrolls bypass the hysteresis "
        "counter (#1731)."
    )
    # The threshold must be more than zero so a single-pixel jitter (e.g. a
    # browser rounding rAF reflow) doesn't unpin, but small enough that a
    # real wheel/trackpad up-tick is caught.
    assert "_lastScrollTop-2" in block or "top<_lastScrollTop -" in block, (
        "Upward detection must allow a small (~2px) tolerance against "
        "sub-pixel scroll noise (#1731)."
    )


def test_upward_scroll_unpins_immediately_without_hysteresis():
    """Upward motion sets _scrollPinned=false and resets the counter, no count needed."""
    block = _scroll_listener_block()
    if_idx = block.index("if(movedUp)")
    # Tolerate either single-line or multi-line if/else formatting.
    else_idx = block.find("else", if_idx)
    assert else_idx > if_idx, "upward / downward branches not found (#1731)"
    upward_branch = block[if_idx:else_idx]

    assert "_scrollPinned=false" in upward_branch, (
        "Upward scroll must set _scrollPinned=false immediately so the "
        "next streaming token does not re-snap to bottom (#1731)."
    )
    assert "_nearBottomCount=0" in upward_branch, (
        "Upward scroll must reset _nearBottomCount so a subsequent "
        "downward motion has to clear the hysteresis fresh (#1731)."
    )
    assert "_nearBottomCount>=2" not in upward_branch, (
        "The upward branch must not gate unpinning on hysteresis — that "
        "was the bug (#1731)."
    )


def test_upward_motion_only_unpins_after_recent_user_intent():
    """Layout/programmatic scrollTop decreases must not masquerade as user scroll-up.

    Long-session windowing can preserve/restore scroll positions while the live
    stream is growing. If a plain scrollTop decrease always clears
    ``_scrollPinned``, the viewport can be visually at bottom while the state says
    "not pinned", so streaming stops auto-following. Explicit wheel/touch upward
    input must still unpin immediately; passive layout movement must not.
    """
    assert "let _lastMessageUpwardIntentMs=" in UI_JS, (
        "ui.js must track recent upward wheel/touch intent inside #messages so "
        "programmatic/layout scroll changes do not permanently unpin streaming."
    )
    assert "function _recentMessageUpwardIntent()" in UI_JS, (
        "ui.js must expose a recent upward transcript intent helper."
    )
    block = _scroll_listener_block()
    moved_idx = block.index("const movedUp=")
    moved_expr = block[moved_idx : block.find(";", moved_idx)]
    assert "_recentMessageUpwardIntent()" in moved_expr, (
        "movedUp must require recent wheel/touch upward intent, not only a "
        "scrollTop decrease caused by DOM/layout changes."
    )


def test_wheel_touch_upward_intent_is_recorded_inside_messages():
    """Wheel/touch gestures inside #messages must mark real upward user intent."""
    fn_start = UI_JS.index("function _recordNonMessageScrollIntent")
    fn_end = UI_JS.index("function _recentNonMessageScrollIntent", fn_start)
    fn = UI_JS[fn_start:fn_end]
    assert "_lastMessageUpwardIntentMs=performance.now()" in fn, (
        "_recordNonMessageScrollIntent must timestamp real upward transcript "
        "wheel/touch gestures before clearing _scrollPinned."
    )
    assert "e.deltaY<0" in fn and "e.type==='touchmove'" in fn, (
        "Both wheel-up and touchmove gestures inside #messages should count as "
        "user upward intent."
    )


def test_downward_path_preserves_macos_momentum_hysteresis():
    """Downward / stationary motion must still go through the original
    hysteresis re-pin path so the #1360 macOS trackpad momentum protection
    is preserved.
    """
    block = _scroll_listener_block()
    else_idx = block.index("else", block.index("if(movedUp)"))
    # End of else branch is at the next btn lookup line.
    end_idx = block.index("const btn=", else_idx)
    downward_branch = block[else_idx:end_idx]

    assert "if(nearBottom)" in downward_branch, (
        "Downward path must branch on near-bottom state so the macOS momentum "
        "re-pin guard still applies (#1360)."
    )
    assert "_nearBottomCount=_nearBottomCount+1" in downward_branch, (
        "Downward path must keep incrementing the near-bottom counter so "
        "the macOS momentum re-pin guard still applies (#1360)."
    )
    assert "if(_nearBottomCount>=2) _scrollPinned=true" in downward_branch, (
        "Downward path must keep the >=2 hysteresis re-pin requirement "
        "without downgrading an explicit bottom pin on the first near-bottom event (#1360)."
    )


def test_repin_threshold_is_still_250px():
    """The 250px near-bottom dead zone is locked in by #1360 / #677 and must
    stay. Direction detection is the new lever, not threshold relaxation.
    """
    block = _scroll_listener_block()
    assert "clientHeight<250" in block, (
        "The 250px re-pin dead zone must remain — #1360 / #677 require it "
        "for macOS small-window + trackpad momentum cases. The #1731 fix "
        "uses direction detection, not threshold changes."
    )


def test_programmatic_scroll_guard_still_skips_listener():
    """Programmatic scrolls must continue to short-circuit the listener so
    they don't pollute _lastScrollTop. (We bail before scheduling the rAF.)
    """
    anchor = "el.addEventListener('scroll'"
    start = UI_JS.index(anchor)
    brace = UI_JS.index("{", start)
    end = UI_JS.index("})", brace)
    listener = UI_JS[brace:end]

    bail_idx = listener.index("if(_programmaticScroll) return")
    raf_idx = listener.index("requestAnimationFrame")
    assert bail_idx < raf_idx, (
        "The _programmaticScroll guard must run before requestAnimationFrame "
        "so programmatic scrollToBottom() calls never update _lastScrollTop "
        "and never spuriously unpin (#1731)."
    )
