"""Regression tests for the issue #5637 streaming stale-anchor guards.

Once the reader is up in history during a live stream, the anchor captured for a
same-frame restore goes stale as the streaming chunk grows content ABOVE the viewport:
the anchor's captured topOffset (and the absolute snapshot.top) no longer map to the
same content, so realigning to them yanks a still reader backward by a few hundred px.
The existing `snapshot.userUnpinned===true` fallback skip is defeated because the
scrollHeight-collapse scroll event re-pins the state machine (flips userUnpinned back to
false) mid-stream.

Two guards close this, both keyed on content-growth-since-capture + absence of real
input intent (NOT a scrollTop diff, which the browser's own overflow-anchor writes on an
overflow-anchor:auto container):
  1. `_restoreMessageViewportAnchor` refuses the realign write.
  2. the absolute `snapshot.top` fallback in `_restoreMessageScrollSnapshotSameFrame`
     refuses its write.

Every behavioral test below is designed to FAIL on the pre-guard code and PASS only with
the guard. Node-harness pattern (extractFunc + mock DOM) shared with the sibling scroll
regression suites.
"""
import json
import pathlib
import shutil
import subprocess
import tempfile

import pytest

ROOT = pathlib.Path(__file__).parent.parent
UI_JS_PATH = ROOT / "static" / "ui.js"
NODE = shutil.which("node")

pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")


def _run_node(source: str) -> str:
    with tempfile.NamedTemporaryFile(
        "w", suffix=".cjs", encoding="utf-8", dir=ROOT, delete=False
    ) as script:
        script.write(source)
        script_path = pathlib.Path(script.name)
    try:
        result = subprocess.run(
            [NODE, str(script_path)], cwd=str(ROOT),
            capture_output=True, text=True, timeout=30,
        )
    finally:
        script_path.unlink(missing_ok=True)
    if result.returncode != 0:
        raise RuntimeError(result.stderr)
    return result.stdout.strip()


def _extract_js_function(js: str, name: str) -> str:
    """Extract a production function without runtime eval/source execution."""
    marker = f"function {name}("
    start = js.index(marker)
    brace = js.index("{", start)
    depth = 0
    quote = None
    escaped = False
    line_comment = False
    block_comment = False
    index = brace
    while index < len(js):
        char = js[index]
        next_char = js[index + 1] if index + 1 < len(js) else ""
        if line_comment:
            if char == "\n":
                line_comment = False
            index += 1
            continue
        if block_comment:
            if char == "*" and next_char == "/":
                block_comment = False
                index += 2
                continue
            index += 1
            continue
        if quote:
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == quote:
                quote = None
            index += 1
            continue
        if char == "/" and next_char == "/":
            line_comment = True
            index += 2
            continue
        if char == "/" and next_char == "*":
            block_comment = True
            index += 2
            continue
        if char in "'\"`":
            quote = char
            index += 1
            continue
        if char == "{":
            depth += 1
        elif char == "}":
            depth -= 1
            if depth == 0:
                return js[start : index + 1]
        index += 1
    raise AssertionError(f"JavaScript function {name} did not close")


def _live_render_ownership_harness(*, move_to_bottom: bool) -> str:
    """Compose real input production with the production queued live-render restore."""
    js = UI_JS_PATH.read_text(encoding="utf-8")
    functions = [
        "renderLiveAnchorActivityScene",
        "_freshProgrammaticScrollActive",
        "_recordNonMessageScrollIntent",
        "_captureMessageScrollSnapshot",
        "_messageScrollSnapshotInputChanged",
        "_abandonMessageScrollSnapshot",
        "_restorePinnedMessageScrollSnapshot",
        "_restoreMessageScrollSnapshotSameFrame",
        "_prepareLiveAnchorScrollRebuildGuard",
        "_restoreLiveAnchorScrollSnapshotAfterRebuild",
    ]
    production = "\n".join(_extract_js_function(js, name) for name in functions)
    target_delta = 100 if move_to_bottom else -100
    final_top = 90026 if move_to_bottom else 89000
    return production + f"""
const PROGRAMMATIC_SCROLL_VALID_MS=150;
let writes=[];
let _messageScrollInputGeneration=0;
let _messageUserUnpinned={str(move_to_bottom).lower()};
let _scrollPinned={str(not move_to_bottom).lower()};
let _nearBottomCount=0;
let _lastScrollTop=null, _lastMessageClientHeight=null;
let _programmaticScroll=false, _programmaticScrollSetAt=0;
let recordWrites=true;
const callbacks=[];
const performance={{now(){{return 1;}}}};
const el={{
  scrollHeight:90453, clientHeight:427,
  get scrollTop(){{return this._top;}},
  set scrollTop(value){{if(recordWrites) writes.push(Math.round(value)); this._top=value;}},
  _top:{'89577' if move_to_bottom else '90026'},
  contains(target){{return target===this;}},
  querySelector(){{return null;}},
}};
const document={{getElementById(id){{return id==='messages'?el:null;}}}};
const msgInner={{style:{{}},dataset:{{}}}};
const emptyState={{style:{{display:''}}}};
const turn={{
  dataset:{{}},
  setAttribute(){{}},
  querySelectorAll(){{return [];}},
}};
const blocks={{querySelectorAll(){{return [];}}}};
function $(id){{
  if(id==='messages') return el;
  if(id==='msgInner') return msgInner;
  if(id==='emptyState') return emptyState;
  if(id==='liveAssistantTurn') return turn;
  return null;
}}
function _captureMessageViewportAnchor(){{return null;}}
function _shouldFollowMessagesOnDomReplace(){{return true;}}
function _deferClearProgrammaticScroll(){{}}
function _cancelBottomSettle(){{}}
function _restoreMessageViewportAnchor(){{return false;}}
function _remountMessageViewportAnchor(){{return false;}}
function _desktopAnchorRealignDelta(){{return null;}}
function _isTouchLikeMessageViewport(){{return false;}}
function _recentMessageScrollIntent(){{return false;}}
function _recentMessageTouchScrollIntent(){{return false;}}
function _restoreMessageScrollSnapshotSameFrameFallback(){{}}
function requestAnimationFrame(callback){{callbacks.push(callback);}}
function chatActivityMode(){{return 'compact_worklog';}}
function isSimplifiedToolCalling(){{return true;}}
const S={{session:{{session_id:'sid-1',pending_started_at:1}},activeStreamId:'stream-1'}};
function _anchorSceneRowsForRendering(){{return [];}}
function _assistantTurnBlocks(){{return blocks;}}
function _captureWorklogDetailDisclosureState(){{return null;}}
function _anchorSceneWorklogGroup(){{return {{}};}}
function _renderAnchorSceneRowsIntoWorklog(){{return true;}}
function _restoreWorklogDetailDisclosureState(){{}}
function _startActivityElapsedTimer(){{}}
function _dedupeLiveProcessedWorklogAnchors(){{}}
function _moveLiveRunStatusToTurnEnd(){{}}
function scrollIfPinned(){{}}
const rendered=renderLiveAnchorActivityScene('stream-1',{{activity_rows:[]}},{{sessionId:'sid-1'}});
const initialWrites=writes.slice();
writes=[];
const guardQueued=callbacks.length>0;
recordWrites=false;
_recordNonMessageScrollIntent({{target:el,deltaY:{target_delta}}});
el.scrollTop={final_top};
recordWrites=true;
callbacks.shift()();
console.log(JSON.stringify({{rendered,guardQueued,initialWrites,writes, finalScrollTop:el.scrollTop,
  messageUserUnpinned:_messageUserUnpinned, scrollPinned:_scrollPinned,
  generation:_messageScrollInputGeneration}}));
"""


def test_live_render_queue_preserves_real_upward_input_from_pinned_capture():
    """The real producer and queued live-render callback preserve upward input."""
    result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=False)))
    assert result == {
        "rendered": True,
        "guardQueued": True,
        "initialWrites": [90026],
        "writes": [],
        "finalScrollTop": 89000,
        "messageUserUnpinned": True,
        "scrollPinned": False,
        "generation": 1,
    }


def test_live_render_queue_repins_real_downward_input_at_bottom():
    """The real producer and queued live-render callback re-pin at true bottom."""
    result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=True)))
    assert result == {
        "rendered": True,
        "guardQueued": True,
        "initialWrites": [89577],
        "writes": [],
        "finalScrollTop": 90026,
        "messageUserUnpinned": False,
        "scrollPinned": True,
        "generation": 1,
    }


def _extract_func_script(js: str) -> str:
    prelude = "const src = " + json.dumps(js) + ";\n"
    body = r"""
function extractFunc(name) {
  const re = new RegExp('function\\s+' + name + '\\s*\\(');
  const start = src.search(re);
  if (start < 0) throw new Error(name + ' not found');
  let i = src.indexOf('{', start);
  let depth = 1; i++;
  let str = null, inLine = false, inBlock = false, inRegex = false, prev = '';
  while (depth > 0 && i < src.length) {
    const c = src[i], n = src[i + 1];
    if (inLine) { if (c === '\n') inLine = false; i++; continue; }
    if (inBlock) { if (c === '*' && n === '/') { inBlock = false; i++; } i++; continue; }
    if (str) { if (c === '\\') { i += 2; continue; } if (c === str) str = null; i++; continue; }
    if (inRegex) { if (c === '\\') { i += 2; continue; } if (c === '/') inRegex = false; i++; continue; }
    if (c === '/' && n === '/') { inLine = true; i += 2; continue; }
    if (c === '/' && n === '*') { inBlock = true; i += 2; continue; }
    if (c === '"' || c === "'" || c === '`') { str = c; i++; continue; }
    if (c === '/' && !'})]0123456789'.includes(prev) && !/[A-Za-z_$]/.test(prev)) { inRegex = true; i++; continue; }
    if (c === '{') depth++; else if (c === '}') depth--;
    if (c.trim()) prev = c;
    i++;
  }
  return src.slice(start, i);
}"""
    return prelude + body


# ---- realign guard (_restoreMessageViewportAnchor) -------------------------------

def _realign_harness(*, anchor_extra: str, cur_scroll_height: int, rect_top: int,
                     top_offset: int, active_intent: bool = False,
                     touch_like: bool = True) -> str:
    js = UI_JS_PATH.read_text(encoding="utf-8")
    intent_js = "true" if active_intent else "false"
    touch_js = "true" if touch_like else "false"
    return _extract_func_script(js) + f"""
let writes = [];
let stTop = 90030;
const row = {{ getBoundingClientRect(){{ return {{ top: {rect_top} }}; }},
  getClientRects(){{ return [{{}}]; }}, dataset: {{ messageAnchorKey: 'k1' }} }};
const container = {{
  get scrollTop(){{ return stTop; }}, set scrollTop(v){{ writes.push(Math.round(v)); stTop = v; }},
  scrollHeight: {cur_scroll_height}, clientHeight: 427,
  getBoundingClientRect(){{ return {{ top: 0, bottom: 427 }}; }},
  querySelectorAll(){{ return [row]; }}, querySelector(){{ return row; }}, style: {{}},
}};
function $(id){{ return id === 'messages' ? container : null; }}
function _recentMessageScrollIntent(){{ return {intent_js}; }}
function _recentMessageTouchScrollIntent(){{ return {intent_js}; }}
function _isTouchLikeMessageViewport(){{ return {touch_js}; }}
let _programmaticScroll = false; let _programmaticScrollSetAt = 0;
const performance = {{ now(){{ return 1; }} }};
function _suppressBrowserOverflowAnchor(){{ return null; }}
function _deferClearProgrammaticScroll(){{}}
function requestAnimationFrame(cb){{ cb(); }}
function setTimeout(cb){{ cb(); return 1; }}
const anchor = Object.assign({{ rawIdx: 53, sessionIdx: 53, key: 'k1', topOffset: {top_offset} }}, {anchor_extra});
eval(extractFunc('_restoreMessageViewportAnchor'));
const returned = _restoreMessageViewportAnchor(anchor, 0);
console.log(JSON.stringify({{ returned, wrote: writes.length }}));
"""


def test_realign_refuses_stale_anchor_when_content_grew_no_intent():
    """Content grew since capture (90000->90453) and no input intent, realign delta -453
    -> REFUSE (return false, no write). Mutation: delete the guard block and it writes."""
    m = json.loads(_run_node(_realign_harness(
        anchor_extra="{scrollHeightAtCapture: 90000}",
        cur_scroll_height=90453, rect_top=-453, top_offset=0, active_intent=False,
    )))
    assert m["returned"] is False and m["wrote"] == 0


def test_realign_allows_fresh_anchor_no_growth():
    """No growth since capture -> realign runs normally even with the same delta."""
    m = json.loads(_run_node(_realign_harness(
        anchor_extra="{scrollHeightAtCapture: 90453}",
        cur_scroll_height=90453, rect_top=-453, top_offset=0, active_intent=False,
    )))
    assert m["returned"] is True and m["wrote"] == 1


def test_realign_allows_with_active_intent():
    """Recent real input intent -> keep the legitimate realign even if content grew."""
    m = json.loads(_run_node(_realign_harness(
        anchor_extra="{scrollHeightAtCapture: 90000}",
        cur_scroll_height=90453, rect_top=-453, top_offset=0, active_intent=True,
    )))
    assert m["returned"] is True and m["wrote"] == 1


def test_realign_backward_compatible_without_capture_geometry():
    """Legacy anchor without scrollHeightAtCapture -> guard is a no-op, prior behavior."""
    m = json.loads(_run_node(_realign_harness(
        anchor_extra="{}",
        cur_scroll_height=90453, rect_top=-453, top_offset=0, active_intent=False,
    )))
    assert m["returned"] is True and m["wrote"] == 1


def test_realign_allows_on_desktop_no_native_anchor():
    """Desktop regression (#5637 gate cert): the exact stale-anchor case
    (content grew, no intent, delta -453) but on a hover+fine-pointer viewport where
    `.messages` is `overflow-anchor:none`. There is no native anchoring layer to hold
    the reader, so the guard MUST NOT refuse — the semantic scrollTop realign has to
    run or the desktop reader is left unheld after above-viewport growth.
    Mutation: drop the `_touchHold&&` term from the realign guard and this FAILS
    (the write is wrongly refused on desktop)."""
    m = json.loads(_run_node(_realign_harness(
        anchor_extra="{scrollHeightAtCapture: 90000}",
        cur_scroll_height=90453, rect_top=-453, top_offset=0, active_intent=False,
        touch_like=False,
    )))
    assert m["returned"] is True and m["wrote"] == 1


# ---- fallback guard (_restoreMessageScrollSnapshotSameFrame) ---------------------

def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top,
                      active_intent: bool = False, touch_like: bool = True,
                      init_scroll_top: int = 90030,
                      snapshot_user_unpinned: bool = False,
                      snapshot_pinned: bool = False,
                      anchor=None, anchor_row_content_pos=None, container_top: int = 0,
                      top_pad_now=None, input_generation: int = 0,
                      delayed_input_generation=None, delayed_scroll_top: int = 89000,
                      restore_fn: str = "_restoreMessageScrollSnapshotSameFrame") -> str:
    """Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame.

    SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's
    getBoundingClientRect().top is computed LIVE as `rowContentPos - scrollTop + container_top`
    — exactly how a real browser reports it — NOT a fixed constant. So after the fix
    writes `el.scrollTop += delta`, a subsequent rect read reflects the new position, and
    a certified hold number is physically realizable.

    anchor: dict for snapshot.anchor (with key/sessionIdx/topOffset[/topPadBefore]) or None.
    anchor_row_content_pos: the row's ABSOLUTE content position (document-space top). Its
      live viewport offset is rowContentPos - scrollTop. When None, the row lookup returns
      nothing (genuinely-gone anchor -> topPad-delta or raw).
    top_pad_now: current virtual top-spacer height (for the genuinely-gone topPad-delta path).
    """
    js = UI_JS_PATH.read_text(encoding="utf-8")
    intent_js = "true" if active_intent else "false"
    touch_js = "true" if touch_like else "false"
    sh = "null" if snapshot_scroll_height is None else str(snapshot_scroll_height)
    anchor_js = "null" if anchor is None else json.dumps(anchor)
    row_present = anchor is not None and anchor_row_content_pos is not None
    row_pos_js = "0" if anchor_row_content_pos is None else str(anchor_row_content_pos)
    pad_now_js = "null" if top_pad_now is None else str(top_pad_now)
    return _extract_func_script(js) + f"""
let writes = [];
let stTop = {init_scroll_top};
let _messageScrollInputGeneration = {input_generation};
const CONTAINER_TOP = {container_top};
const ROW_CONTENT_POS = {row_pos_js};
const ROW_PRESENT = {"true" if row_present else "false"};
const TOP_PAD_NOW = {pad_now_js};
// A real browser's rect.top for a row is (rowContentPos - scrollTop) relative to the
// document, so its offset below the container top is ROW_CONTENT_POS - scrollTop.
const _anchorRow = ROW_PRESENT ? {{
  getBoundingClientRect(){{ return {{ top: CONTAINER_TOP + (ROW_CONTENT_POS - stTop) }}; }},
  getClientRects(){{ return [{{}}]; }},
}} : null;
const _topSpacer = (TOP_PAD_NOW === null) ? null : {{
  style: {{ height: TOP_PAD_NOW + 'px' }},
}};
const el = {{
  get scrollTop(){{ return stTop; }}, set scrollTop(v){{ writes.push(Math.round(v)); stTop = v; }},
  scrollHeight: {cur_scroll_height}, clientHeight: 427,
  getBoundingClientRect(){{ return {{ top: CONTAINER_TOP, bottom: CONTAINER_TOP + 427 }}; }},
  querySelector(sel){{
    if (sel === '[data-virtual-spacer="before"]') return _topSpacer;
    return _anchorRow;
  }},
  querySelectorAll(sel){{ return _anchorRow ? [_anchorRow] : []; }},
}};
function $(id){{ return id === 'messages' ? el : null; }}
function _recentMessageScrollIntent(){{ return {intent_js}; }}
function _recentMessageTouchScrollIntent(){{ return {intent_js}; }}
function _isTouchLikeMessageViewport(){{ return {touch_js}; }}
// realign path fails (no anchor restore) so execution reaches the absolute fallback
function _restorePinnedMessageScrollSnapshot(snapshot){{
  if(snapshot.pinned!==true) return false;
  const pinnedTarget=el.scrollHeight-el.clientHeight-(Number(snapshot.bottom)||0);
  el.scrollTop=pinnedTarget;
  return true;
}}
function _restoreMessageViewportAnchor(){{ return false; }}
function _remountMessageViewportAnchor(){{ return false; }}
let _messageUserUnpinned = false; let _scrollPinned = true; let _nearBottomCount = 5;
let _lastScrollTop = 0; let _lastMessageClientHeight = 0;
let _programmaticScroll = false; let _programmaticScrollSetAt = 0;
const performance = {{ now(){{ return 1; }} }};
function _deferClearProgrammaticScroll(){{}}
function requestAnimationFrame(cb){{ cb(); }}
function setTimeout(cb){{ cb(); return 1; }}
const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40,
  scrollHeight: {sh}, inputGeneration: {input_generation}, pinned: {str(snapshot_pinned).lower()}, userUnpinned: {str(snapshot_user_unpinned).lower()} }};
eval(extractFunc('_desktopAnchorRealignDelta'));
eval(extractFunc('_messageScrollSnapshotInputChanged'));
eval(extractFunc('_abandonMessageScrollSnapshot'));
eval(extractFunc({json.dumps(restore_fn)}));
{restore_fn}(snapshot);
{f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; {restore_fn}(snapshot);" if delayed_input_generation is not None else ""}
console.log(JSON.stringify({{ wrote: writes.length, writes,
  messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }}));
"""


def test_fallback_refuses_stale_snapshot_top_when_content_grew_no_intent():
    """Content grew since snapshot (90000->90453), reader not pinned, no intent, and the
    absolute snapshot.top write would move scrollTop >8px -> REFUSE. This is the -578
    on-device jump the userUnpinned check missed (re-pin flipped userUnpinned false).
    Mutation: delete the fallback guard block and scrollHistory gets the stale write."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=90453, snapshot_top=89577,
        active_intent=False,
    )))
    assert m["writes"] == []
    assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False


def test_fallback_allows_snapshot_top_when_no_growth():
    """No growth since snapshot -> the fallback keeps its authoritative absolute restore."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
        active_intent=False,
    )))
    assert m["writes"] == [89577]


def test_fallback_backward_compatible_without_capture_scrollheight():
    """Legacy snapshot without scrollHeight -> guard no-op, prior absolute restore runs."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=None, cur_scroll_height=90453, snapshot_top=89577,
        active_intent=False,
    )))
    assert m["writes"] == [89577]


def test_fallback_allows_snapshot_top_with_active_intent():
    """Content grew since snapshot, but the reader has recent real input intent -> the
    fallback keeps its legitimate absolute restore (an actively-scrolling reader owns the
    snapshot). Mirrors the realign-guard active-intent case.
    Mutation: drop the `!_activeIntent` term from the fallback guard and this fails
    (the write would be wrongly refused)."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=90453, snapshot_top=89577,
        active_intent=True,
    )))
    assert m["writes"] == [89577]


def test_fallback_allows_snapshot_top_on_desktop_no_native_anchor():
    """Desktop regression (#5637 gate cert): the exact stale-snapshot case (content grew,
    reader not pinned, no intent, absolute write would move >8px) but on a
    hover+fine-pointer viewport where `.messages` is `overflow-anchor:none`. With no
    native anchoring layer to hold the reader, the fallback MUST still WRITE rather than
    refuse-and-latch userUnpinned. With NO anchor on the snapshot (and no top-spacer
    geometry) there is nothing to realign against, so it keeps the raw snapshot.top —
    the same authoritative absolute write as before.
    Mutation: drop the `_fbTouchHold&&` term from the fallback guard and this FAILS
    (the write is wrongly refused and userUnpinned is latched on desktop)."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=90453, snapshot_top=89577,
        active_intent=False, touch_like=False,
    )))
    assert m["writes"] == [89577]
    assert m["messageUserUnpinned"] is False and m["scrollPinned"] is True


def test_unpinned_anchor_miss_restores_on_desktop_without_native_anchor():
    """A desktop reader who deliberately scrolled up cannot be left to native
    anchoring: desktop disables it. If the semantic row is temporarily absent,
    the rebuild may clamp scrollTop toward zero before this function runs, so the
    fallback must restore the snapshot rather than taking the mobile no-write exit.
    """
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=90453, snapshot_top=89577,
        active_intent=False, touch_like=False, snapshot_user_unpinned=True,
        init_scroll_top=0,
    )))
    assert m["writes"] == [89577]
    assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False


def test_delayed_restore_cannot_overwrite_new_desktop_input():
    """The synchronous restore owns generation 0, then the reader moves to 89000
    before the delayed rAF restore. The second call must abandon the stale snapshot
    instead of writing its old 89577 target."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
        active_intent=False, touch_like=False, snapshot_user_unpinned=True,
        init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
    )))
    assert m["writes"] == [89577]
    assert m["finalScrollTop"] == 89000
    assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False


@pytest.mark.parametrize("restore_fn", [
    "_restoreMessageScrollSnapshot",
    "_restoreMessageScrollSnapshotSameFrame",
])
def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(restore_fn):
    """A pinned-at-capture snapshot must lose ownership before its tail write.

    The reader moves upward before the delayed restore; the stale pinned snapshot
    must not put them back at its captured tail-relative position.
    """
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
        active_intent=False, touch_like=False, snapshot_pinned=True,
        init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
        delayed_scroll_top=89000,
        restore_fn=restore_fn,
    )))
    assert m["writes"] == [89986]
    assert m["finalScrollTop"] == 89000
    assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False


@pytest.mark.parametrize("restore_fn", [
    "_restoreMessageScrollSnapshot",
    "_restoreMessageScrollSnapshotSameFrame",
])
def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(restore_fn):
    """Abandoning a stale unpinned snapshot must reconcile a live bottom position."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
        active_intent=False, touch_like=False, snapshot_user_unpinned=True,
        init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
        delayed_scroll_top=90026,
        restore_fn=restore_fn,
    )))
    assert m["writes"] == [89577]
    assert m["finalScrollTop"] == 90026
    assert m["messageUserUnpinned"] is False and m["scrollPinned"] is True


# ---- round-3 desktop anchor-realign (PR #5742): app's own scrollTop += delta idiom -----
# These use SCROLL-DEPENDENT rect mocks (rect.top = rowContentPos - scrollTop), so the
# certified hold numbers are physically realizable in a real browser — the round-2
# gate-cert requirement that fixed-rect mocks violated.

def test_realign_holds_reader_on_above_viewport_growth_desktop():
    """Reader parked up in history on desktop; above-viewport content grew since capture.
    The anchor row was captured at topOffset 1000; the reader has ALREADY been carried
    down by the browser to scrollTop 1480 (partial), and the row's content position is
    2000 so its live offset is 2000-1480=520. The app idiom writes
    scrollTop += (currentOffset - capturedOffset) = 1480 + (520 - 1000) = 1000, landing
    the row back at its captured 1000px offset (2000 - 1000 = 1000). That HOLD is
    physically realizable: after the write, rect.top = 2000 - 1000 = 1000 = capturedOffset.
    Mutation: revert `_fbTarget` to the raw `target` (snapshot.top=1200) and this FAILS
    (the reader is left at 1200, a 200px drift from the held content position)."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=90600, snapshot_top=1200,
        active_intent=False, touch_like=False,
        anchor={"key": "k1", "sessionIdx": 5, "topOffset": 1000},
        anchor_row_content_pos=2000, container_top=0, init_scroll_top=1480,
    )))
    # scrollTop += (currentOffset - capturedOffset) = 1480 + ((2000-1480) - 1000) = 1000
    assert m["writes"] == [1000]
    # and the row is now held at its captured offset — physically consistent
    assert m["finalScrollTop"] == 1000
    assert m["messageUserUnpinned"] is False and m["scrollPinned"] is True


def test_realign_is_noop_when_already_aligned_desktop():
    """No arbiter: when the reader is already at the captured offset (row content pos 2000,
    scrollTop 1000 -> live offset 1000 == captured 1000), the realign delta is 0 and no
    move happens (delta < clamp is still written as the same value, i.e. a no-op write to
    the same scrollTop). Proves the idiom does not perturb an already-correct position."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=90600, snapshot_top=1200,
        active_intent=False, touch_like=False,
        anchor={"key": "k1", "sessionIdx": 5, "topOffset": 1000},
        anchor_row_content_pos=2000, container_top=0, init_scroll_top=1000,
    )))
    # currentOffset = 2000 - 1000 = 1000 == captured 1000 -> delta 0 -> scrollTop stays 1000
    assert m["finalScrollTop"] == 1000
    assert m["writes"] == [1000]


def test_realign_does_not_drift_on_below_viewport_tail_growth_desktop():
    """Below-viewport (tail) growth: the anchor row ABOVE the reader did NOT move
    (content pos 2000, scrollTop 1000 -> offset 1000 == captured 1000), only content
    below grew (scrollHeight 90000 -> 95000). The realign delta is 0 -> the reader is
    NOT pulled downward. This is the conveyor-belt drift the round-2 arbiter formula
    reintroduced; the app idiom cannot, because it keys on the ABOVE-viewport anchor row,
    whose offset is unchanged by tail growth.
    Mutation: a `snapshot.top + totalGrowth` formula would write 1000 + 5000 = 6000 and
    drift the reader down 5000px; this asserts the held 1000."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=95000, snapshot_top=1000,
        active_intent=False, touch_like=False,
        anchor={"key": "k1", "sessionIdx": 5, "topOffset": 1000},
        anchor_row_content_pos=2000, container_top=0, init_scroll_top=1000,
    )))
    assert m["finalScrollTop"] == 1000
    assert m["writes"] == [1000]


def test_realign_genuinely_gone_anchor_uses_toppad_delta_desktop():
    """Anchor row genuinely gone (lookup returns null), but the snapshot carries
    topPadBefore and the current virtual top-spacer is measurable. Mirror the topPad-delta
    idiom: shift scrollTop by (padNow - padBefore) so the reader is held by the same
    amount the content above moved. padBefore=800, padNow=1300 -> +500 from scrollTop 1000
    -> 1500.
    Mutation: drop the topPad-delta branch and it keeps raw snapshot.top (1200), a
    300px drift from the topPad-held 1500."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=90500, snapshot_top=1200,
        active_intent=False, touch_like=False,
        anchor={"key": "gone", "sessionIdx": 999, "topOffset": 1000, "topPadBefore": 800},
        anchor_row_content_pos=None, container_top=0, init_scroll_top=1000,
        top_pad_now=1300,
    )))
    assert m["finalScrollTop"] == 1500
    assert m["writes"] == [1500]


def test_realign_genuinely_gone_anchor_no_geometry_keeps_raw_desktop():
    """Anchor row gone AND no topPad geometry available -> keep the raw snapshot.top
    (no guessing with an arbiter). snapshot.top=1200 -> writes 1200."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=90500, snapshot_top=1200,
        active_intent=False, touch_like=False,
        anchor={"key": "gone", "sessionIdx": 999, "topOffset": 1000},
        anchor_row_content_pos=None, container_top=0, init_scroll_top=1000,
        top_pad_now=None,
    )))
    assert m["writes"] == [1200]


def test_realign_gone_anchor_null_toppadbefore_does_not_fling_desktop():
    """greptile P1: anchor row gone, a virtual top-spacer IS present (padNow=1300), but the
    snapshot carries NO captured topPadBefore (null). Number(null) is 0, so a naive
    isFinite(padBefore) check would treat padBefore as 0 and add the ENTIRE 1300px spacer
    to scrollTop (1000 -> 2300), flinging the reader far from their content. The guard
    requires an ACTUAL captured topPadBefore, so with null it keeps the raw snapshot.top.
    Mutation: drop the `_padBeforeRaw!=null` term and this FAILS (writes 2300, the fling)."""
    m = json.loads(_run_node(_fallback_harness(
        snapshot_scroll_height=90000, cur_scroll_height=90500, snapshot_top=1200,
        active_intent=False, touch_like=False,
        anchor={"key": "gone", "sessionIdx": 999, "topOffset": 1000, "topPadBefore": None},
        anchor_row_content_pos=None, container_top=0, init_scroll_top=1000,
        top_pad_now=1300,
    )))
    assert m["writes"] == [1200]




def _predicate_harness(*, pointer_coarse, computed_overflow_anchor, has_matchmedia=True,
                       inline_overflow_anchor="", ua="Mozilla/5.0 (Linux; Android 13)",
                       platform="Linux armv8l", max_touch_points=5) -> str:
    """Exercise the REAL _isTouchLikeMessageViewport + _browserOverflowAnchorActive
    + _isIOSWebKit against a fake element, mocking matchMedia, getComputedStyle and
    navigator. `inline_overflow_anchor` simulates the inline `overflowAnchor:'none'`
    that _restoreMessageViewportAnchor writes on #messages mid-realign — the value the
    computed probe would transiently read. `ua`/`platform`/`max_touch_points` drive the
    _isIOSWebKit branch (default: an Android touch device that is NOT iOS)."""
    js = UI_JS_PATH.read_text(encoding="utf-8")
    mm = "true" if pointer_coarse else "false"
    has_mm = "true" if has_matchmedia else "false"
    return _extract_func_script(js) + f"""
const HAS_MM = {has_mm};
if (HAS_MM) {{
  globalThis.matchMedia = function(q){{ return {{ matches: (q === '(pointer:coarse)') ? {mm} : false }}; }};
}} else {{
  globalThis.matchMedia = undefined;
}}
// Node 18+ ships a built-in read-only `navigator`; a plain assignment is silently
// ignored, so force the mock in with defineProperty.
Object.defineProperty(globalThis, 'navigator', {{ configurable: true, value: {{ userAgent: {json.dumps(ua)}, platform: {json.dumps(platform)}, maxTouchPoints: {max_touch_points} }} }});
// getComputedStyle reflects the INLINE override first (as a real browser would during
// a realign burst), else the resting computed value.
const el = {{ style: {{ overflowAnchor: {json.dumps(inline_overflow_anchor)} }} }};
globalThis.getComputedStyle = function(node){{
  const inline = node && node.style && node.style.overflowAnchor;
  return {{ overflowAnchor: inline || {json.dumps(computed_overflow_anchor)} }};
}};
eval(extractFunc('_browserOverflowAnchorActive'));
eval(extractFunc('_isIOSWebKit'));
eval(extractFunc('_isTouchLikeMessageViewport'));
console.log(JSON.stringify({{ touchLike: _isTouchLikeMessageViewport(el) }}));
"""


def test_predicate_stays_true_on_touch_when_inline_anchor_clobbered_to_none():
    """The claim that motivated choosing matchMedia over the computed probe: on a touch
    device, when a prior realign tick has clobbered the inline `overflowAnchor` to 'none'
    on #messages (its own scroll-write side effect, restored next frame), the predicate
    MUST still report touch=true so the hold gate stays engaged across a realign burst.
    matchMedia('(pointer:coarse)') reflects the input device and can't be mutated by that
    inline write.
    Mutation: revert _isTouchLikeMessageViewport to `return _browserOverflowAnchorActive(el)`
    (the computed probe alone) and this FAILS — the probe reads the inline 'none' and
    misclassifies the touch device as desktop."""
    m = json.loads(_run_node(_predicate_harness(
        pointer_coarse=True, computed_overflow_anchor="auto",
        inline_overflow_anchor="none",
    )))
    assert m["touchLike"] is True



def test_predicate_false_on_desktop_fine_pointer():
    """Desktop (fine pointer): matchMedia('(pointer:coarse)') is false and the resting
    computed overflow-anchor is 'none' -> predicate reports NOT touch, so the guards do
    not fire and desktop keeps its scroll restore."""
    m = json.loads(_run_node(_predicate_harness(
        pointer_coarse=False, computed_overflow_anchor="none",
    )))
    assert m["touchLike"] is False


def test_predicate_falls_back_to_computed_probe_without_matchmedia():
    """Best-effort fallback: with no matchMedia available, the predicate uses the computed
    overflow-anchor probe. A resting 'auto' (touch) -> true."""
    m = json.loads(_run_node(_predicate_harness(
        pointer_coarse=True, computed_overflow_anchor="auto", has_matchmedia=False,
    )))
    assert m["touchLike"] is True


# ---- iOS WebKit exclusion (_isIOSWebKit) — overflow-anchor is inert on iOS -------

def test_predicate_false_on_ios_iphone_despite_pointer_coarse():
    """iOS gate cert (#5637 round-2 RED): an iPhone is pointer:coarse with a resting
    computed overflow-anchor of 'auto', so the round-1 predicate would classify it as a
    hold-capable touch viewport and let the stale-anchor refusal fire. But overflow-anchor
    is INERT on iOS WebKit, so refusing the restore leaves a scrolled-up reader unheld —
    the same class as the desktop regression. The predicate MUST report NOT touch on iOS so
    the semantic realign is kept.
    Mutation: remove the `if(_isIOSWebKit()) return false;` line and this FAILS (iPhone is
    misclassified as a hold-capable touch viewport)."""
    m = json.loads(_run_node(_predicate_harness(
        pointer_coarse=True, computed_overflow_anchor="auto",
        ua="Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 "
           "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
        platform="iPhone", max_touch_points=5,
    )))
    assert m["touchLike"] is False


def test_predicate_false_on_ipados13_masquerading_as_mac():
    """iPadOS 13+ reports a desktop UA + platform 'MacIntel' but has touch (maxTouchPoints>1),
    unlike a real Mac. overflow-anchor is inert there too, so it must be excluded from the
    refusal like the iPhone.
    Mutation: drop the `platform==='MacIntel' && maxTouchPoints>1` branch in _isIOSWebKit and
    this FAILS (iPad is treated as an anchor-capable touch device)."""
    m = json.loads(_run_node(_predicate_harness(
        pointer_coarse=True, computed_overflow_anchor="auto",
        ua="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 "
           "(KHTML, like Gecko) Version/17.0 Safari/605.1.15",
        platform="MacIntel", max_touch_points=5,
    )))
    assert m["touchLike"] is False


def test_predicate_true_on_android_not_ios():
    """Android Chrome: pointer:coarse, overflow-anchor works, and it is NOT iOS — this is
    the one platform where the refusal is safe, so the predicate stays true. Guards against
    an over-broad _isIOSWebKit that would also exclude Android.
    Mutation: broaden _isIOSWebKit to match any touch device (e.g. return maxTouchPoints>1)
    and this FAILS (Android would be wrongly excluded, re-opening the mobile jump)."""
    m = json.loads(_run_node(_predicate_harness(
        pointer_coarse=True, computed_overflow_anchor="auto",
        ua="Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) "
           "Chrome/120.0.0.0 Mobile Safari/537.36",
        platform="Linux armv8l", max_touch_points=5,
    )))
    assert m["touchLike"] is True
