"""Regression test for issue #2184 — Fork from here absolute-index fix.

When a long session is loaded with ``msg_limit=30`` (tail window), the
``Fork from here`` button passes ``rawIdx+1`` as ``keep_count`` to
``/api/session/branch``.  ``rawIdx`` is the 0-based index within the
loaded window, but the backend expects an absolute message count from
the beginning of the full transcript.  In a truncated session where
``_oldestIdx > 0``, the local index alone is wrong.

The fix:

1. ``forkFromMessage`` captures ``absoluteKeepCount = _oldestIdx + msgIdx``
   *before* any async work (``_ensureAllMessagesLoaded`` resets
   ``_oldestIdx`` to 0 after its wholesale replace).
2. It calls ``_ensureAllMessagesLoaded()`` so the full transcript is
   available for the forked session.
3. It sends ``keep_count: absoluteKeepCount`` instead of the raw
   ``msgIdx``.

When the full transcript is already loaded (short sessions or sessions
where all older messages have been scrolled in), ``_oldestIdx`` is 0
and ``absoluteKeepCount`` equals ``msgIdx``, preserving existing
behaviour.
"""

import re
from pathlib import Path

REPO = Path(__file__).resolve().parents[1]
COMMANDS_JS = (REPO / "static" / "commands.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")


def _function_body(src: str, name: str) -> str:
    """Slice the body of ``async function <name>`` (or ``function <name>``)."""
    needle_async = f"async function {name}"
    needle_sync = f"function {name}"
    if needle_async in src:
        start = src.index(needle_async)
    else:
        start = src.index(needle_sync)
    brace = src.index("{", start)
    depth = 0
    for i in range(brace, len(src)):
        if src[i] == "{":
            depth += 1
        elif src[i] == "}":
            depth -= 1
            if depth == 0:
                return src[start : i + 1]
    raise AssertionError(f"function {name!r} body not found")


# ---------------------------------------------------------------------------
# forkFromMessage computes absolute keep_count using _oldestIdx
# ---------------------------------------------------------------------------

def test_fork_uses_absolute_keep_count():
    """``forkFromMessage`` must add ``_oldestIdx`` to ``msgIdx`` before sending."""
    body = _function_body(COMMANDS_JS, "forkFromMessage")
    # The function must compute an absolute count that incorporates _oldestIdx.
    assert "_oldestIdx" in body, (
        "forkFromMessage must reference _oldestIdx to compute the absolute "
        "keep_count for truncated sessions. See #2184."
    )
    # The absolute count expression must combine _oldestIdx + msgIdx.
    assert re.search(r"_oldestIdx\s*\+\s*msgIdx", body), (
        "forkFromMessage must compute absoluteKeepCount as _oldestIdx + msgIdx. "
        "See #2184."
    )


def test_fork_captures_absolute_count_before_await():
    """The absolute keep_count must be captured BEFORE any ``await`` call."""
    body = _function_body(COMMANDS_JS, "forkFromMessage")
    capture_match = re.search(r"absoluteKeepCount\s*=\s*_oldestIdx\s*\+\s*msgIdx", body)
    assert capture_match, "Missing absoluteKeepCount = _oldestIdx + msgIdx assignment"
    capture_idx = capture_match.start()
    # Find the first await in the function body.
    await_match = re.search(r"\bawait\b", body)
    assert await_match, "forkFromMessage should contain at least one await"
    await_idx = await_match.start()
    assert capture_idx < await_idx, (
        "forkFromMessage must capture absoluteKeepCount BEFORE the first "
        "await, because _ensureAllMessagesLoaded resets _oldestIdx to 0 "
        "after its wholesale replace. See #2184."
    )


def test_fork_sends_absolute_keep_count_not_raw_msgIdx():
    """The request body must use ``absoluteKeepCount``, not ``msgIdx``."""
    body = _function_body(COMMANDS_JS, "forkFromMessage")
    assert "keep_count:absoluteKeepCount" in body, (
        "forkFromMessage must send keep_count:absoluteKeepCount (not "
        "keep_count:msgIdx) so the backend receives the absolute message "
        "count. See #2184."
    )
    # Ensure the old raw form is gone.
    assert "keep_count:msgIdx" not in body, (
        "forkFromMessage must NOT send keep_count:msgIdx — that is the "
        "pre-fix bug where a tail-window local index was sent as an "
        "absolute count. See #2184."
    )


def test_fork_calls_ensure_all_messages_loaded():
    """``forkFromMessage`` must call ``_ensureAllMessagesLoaded`` for truncated sessions."""
    body = _function_body(COMMANDS_JS, "forkFromMessage")
    assert "_ensureAllMessagesLoaded" in body, (
        "forkFromMessage must call _ensureAllMessagesLoaded so the full "
        "transcript is loaded before the fork is created. See #2184."
    )


def test_fork_ensure_all_called_before_branch_api():
    """``_ensureAllMessagesLoaded`` must be called BEFORE the ``/api/session/branch`` request."""
    body = _function_body(COMMANDS_JS, "forkFromMessage")
    ensure_idx = body.index("_ensureAllMessagesLoaded")
    branch_idx = body.index("'/api/session/branch'")
    assert ensure_idx < branch_idx, (
        "forkFromMessage must call _ensureAllMessagesLoaded BEFORE sending "
        "the branch API request, so the full transcript is available. "
        "See #2184."
    )


# ---------------------------------------------------------------------------
# _oldestIdx and _messagesTruncated are declared at module scope
# ---------------------------------------------------------------------------

def test_oldest_idx_declared_at_module_scope():
    """``_oldestIdx`` must be a module-scoped variable accessible to forkFromMessage."""
    assert "let _oldestIdx = 0;" in SESSIONS_JS, (
        "static/sessions.js must declare `let _oldestIdx = 0;` at module "
        "scope so forkFromMessage can read it for absolute-index "
        "computation. See #2184."
    )


def test_messages_truncated_declared_at_module_scope():
    """``_messagesTruncated`` must be a module-scoped variable."""
    assert "let _messagesTruncated = false;" in SESSIONS_JS, (
        "static/sessions.js must declare `let _messagesTruncated = false;` "
        "at module scope. See #2184."
    )


# ---------------------------------------------------------------------------
# _ensureAllMessagesLoaded resets _oldestIdx (existing #1937 guard)
# ---------------------------------------------------------------------------

def test_ensure_all_resets_oldest_idx_to_zero():
    """After loading all messages, ``_oldestIdx`` must be 0 — this is why we capture early."""
    body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded")
    assert "_oldestIdx = 0;" in body, (
        "_ensureAllMessagesLoaded must reset _oldestIdx to 0 after the "
        "wholesale replace. This is why forkFromMessage must capture the "
        "absolute count BEFORE awaiting _ensureAllMessagesLoaded. "
        "See #2184 and #1937."
    )


def test_ensure_all_messages_uses_extended_timeout_for_full_history_load():
    """Full-history loads for fork/export/start-jump can legitimately exceed the API default timeout."""
    body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded")
    full_history_call = re.search(
        r"api\((?P<url>`[^`]*messages=1&resolve_model=0[^`]*`)\s*,\s*\{(?P<opts>[^}]*)\}\s*\)",
        body,
        re.S,
    )
    assert full_history_call, "_ensureAllMessagesLoaded must pass options to the full-history api() call"
    timeout_match = re.search(r"timeoutMs\s*:\s*(\d+)", full_history_call.group("opts"))
    assert timeout_match, "Full-history api() call must specify timeoutMs"
    assert int(timeout_match.group(1)) >= 120000


# ---------------------------------------------------------------------------
# Short-session / full-transcript behaviour preserved
# ---------------------------------------------------------------------------

def test_fork_absolute_count_reduces_to_msgIdx_when_oldestIdx_zero():
    """When ``_oldestIdx`` is 0, ``absoluteKeepCount`` equals ``msgIdx`` (no behaviour change)."""
    body = _function_body(COMMANDS_JS, "forkFromMessage")
    # The expression _oldestIdx + msgIdx evaluates to msgIdx when _oldestIdx==0.
    # Verify the expression exists (already checked above) and that there
    # is no conditional that would skip the computation for non-truncated sessions.
    assert re.search(r"absoluteKeepCount\s*=\s*_oldestIdx\s*\+\s*msgIdx", body), (
        "The absolute count must always be computed as _oldestIdx + msgIdx. "
        "When _oldestIdx is 0 (full transcript loaded), this equals msgIdx, "
        "preserving short-session behaviour. See #2184."
    )
    # There should NOT be a conditional that only computes the offset for
    # truncated sessions — that would be fragile if the condition and the
    # _oldestIdx read got out of sync.
    assert "if(" not in body.split("absoluteKeepCount")[0].split("\n")[-1], (
        "The absoluteKeepCount computation must not be inside a conditional "
        "that gates on _messagesTruncated — always computing _oldestIdx + "
        "msgIdx is simpler and correct for both truncated and non-truncated "
        "sessions. See #2184."
    )