"""Regression: a stale sidebar unread dot must clear when a session is *visited*.

Salvage of #4946 (originally by @neaucode-bot), rebuilt fresh on master.

The yellow unread dot in the sidebar historically cleared only reliably when the
sidebar **row** was clicked. Opening/visiting a session through other paths — or
re-selecting the already-open session — could leave a stale dot, and a deferred
/api/sessions list poll landing across the async message-load gap could re-flag
the open session as unread.

The fix introduces `_acknowledgeSessionVisit()` (sync viewed count + polling
snapshot + repaint) and wires it into loadSession at three points:

  1. the same-session no-op guard (so re-selecting the open session clears a
     stale dot before returning),
  2. when the session metadata arrives, and
  3. again after the async message-load gap (so a deferred poll cannot leave a
     sticky dot).

Two invariants flagged in review are protected here and MUST NOT regress:

  (a) hidden/background completions must still be marked unread — the visit-ack
      does NOT loosen the focus gate on the completion paths, so a completion in
      a non-visible/non-focused tab is still flagged (concern a).
  (b) cleaning up a visited child's unread state must not strip a lineage
      PARENT's own unread dot — the visit repaints via
      renderSessionListFromCache(), which recomputes each row's aggregated
      unread authoritatively rather than doing ad-hoc DOM surgery (concern b).
"""
import json
import subprocess
import sys
from pathlib import Path

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

# Sibling harness scaffolding lives beside this file and is not on sys.path by
# default; import it the same way the other node-harness tests do.
sys.path.insert(0, str(ROOT / "tests"))
import _unread_store_helpers as unread_store_helpers  # noqa: E402


def _load_session_block() -> str:
    start = SESSIONS_JS.index("async function loadSession(sid")
    end = SESSIONS_JS.index("function _resolveSessionModelForDisplaySoon", start)
    return SESSIONS_JS[start:end]


def _function_block(name: str, next_marker: str) -> str:
    start = SESSIONS_JS.index(f"function {name}")
    end = SESSIONS_JS.index(next_marker, start + 1)
    return SESSIONS_JS[start:end]


# ── Structural anchors ──────────────────────────────────────────────────────

def test_visit_ack_helpers_exist():
    assert "function _acknowledgeSessionVisit(sid, messageCount = 0, lastMessageAt = 0)" in SESSIONS_JS
    assert "function _syncSessionListSnapshotOnVisit(sid, messageCount, lastMessageAt)" in SESSIONS_JS
    assert "function _sessionVisitHasUnreadState(sid)" in SESSIONS_JS


def test_acknowledge_visit_syncs_viewed_snapshot_and_repaints():
    body = _function_block("_acknowledgeSessionVisit", "function _sessionVisitHasUnreadState")
    # Clears viewed count (which clears the stale completion-unread marker, #3020),
    # syncs the polling snapshot, and repaints the sidebar from cache.
    assert "_setSessionViewedCount(sid, messageCount);" in body
    assert "_syncSessionListSnapshotOnVisit(sid, messageCount, lastMessageAt);" in body
    assert "renderSessionListFromCache" in body


def test_load_session_acknowledges_visit_before_and_after_message_load():
    block = _load_session_block()
    # Metadata-arrival acknowledgment.
    first_ack = block.find("_acknowledgeSessionVisit(\n    S.session.session_id,")
    loading_clear = block.find("if (_isCurrentLoad()) _loadingSessionId = null;\n\n  // Re-acknowledge")
    second_ack = block.find("_acknowledgeSessionVisit(", loading_clear)

    assert first_ack != -1, "loadSession must acknowledge the visit when metadata arrives"
    assert loading_clear != -1, "loadSession must clear the in-flight marker before the final acknowledge"
    assert second_ack != -1 and first_ack < loading_clear < second_ack, (
        "loadSession must re-acknowledge after the async message-load gap so a "
        "deferred sidebar poll cannot leave a sticky unread dot"
    )


def test_post_load_reack_is_guarded_by_active_view():
    # #5917 gate finding: the post-load re-ack must be gated on
    # _isSessionActivelyViewedForList(sid). A completion that lands while
    # _ensureMessagesLoaded() is in flight AND the tab then goes hidden is
    # correctly marked unread — an UNCONDITIONAL post-load ack would wrongly
    # clear that hidden-tab-completion marker.
    block = _load_session_block()
    loading_clear = block.find("if (_isCurrentLoad()) _loadingSessionId = null;\n\n  // Re-acknowledge")
    guard = block.find("_isSessionActivelyViewedForList(sid)", loading_clear)
    second_ack = block.find("_acknowledgeSessionVisit(", loading_clear)
    assert guard != -1 and guard < second_ack, (
        "the post-load re-acknowledge must be guarded by "
        "_isSessionActivelyViewedForList(sid) so a hidden-tab completion stays unread"
    )


def test_same_session_reselect_clears_stale_unread():
    block = _load_session_block()
    guard = block.find("if(currentSid===sid && !forceReload && (!_loadingSessionId || _loadingSessionId===sid)){")
    unread_check = block.find("_sessionVisitHasUnreadState(sid)", guard)
    acknowledge = block.find("_acknowledgeSessionVisit(", unread_check)
    ret = block.find("return;", acknowledge)

    assert guard != -1, "same-session no-op guard must still exist"
    assert unread_check != -1 and guard < unread_check, (
        "re-selecting the already-open session must check for stale unread state"
    )
    assert acknowledge != -1 and unread_check < acknowledge < ret, (
        "re-selecting the already-open session must acknowledge the visit (clearing "
        "the stale dot) before returning"
    )


def test_completion_paths_keep_focus_gate_for_hidden_tab_completions():
    """Concern (a): the visit-ack must NOT loosen the completion paths' focus gate.

    A background completion in a hidden/unfocused tab must still be flagged
    unread, so the background + polling completion paths must keep using the
    focus-gated _isSessionActivelyViewedForList, not a focus-independent variant.
    """
    background = _function_block("_markSessionCompletionUnreadIfBackground", "function _clearSessionCompletionUnread")
    assert "_isSessionActivelyViewedForList(sid)" in background, (
        "background completion must keep the focus-gated read check so a hidden-tab "
        "completion is not prematurely marked read"
    )

    polling_start = SESSIONS_JS.index("function _markPollingCompletionUnreadTransitions(sessions)")
    polling_end = SESSIONS_JS.index("const staleRuntimeStateSids", polling_start)
    polling = SESSIONS_JS[polling_start:polling_end]
    assert "!_isSessionActivelyViewedForList(sid)" in polling, (
        "polling completion must keep the focus-gated read check so a hidden-tab "
        "completion is not prematurely marked read"
    )


# ── Functional behavior via node ────────────────────────────────────────────

def _extract(name: str) -> str:
    """Extract a top-level `function name(...) { ... }` definition by brace match."""
    marker = f"function {name}("
    start = SESSIONS_JS.index(marker)
    brace = SESSIONS_JS.index("{", start)
    depth = 0
    for i in range(brace, len(SESSIONS_JS)):
        ch = SESSIONS_JS[i]
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return SESSIONS_JS[start:i + 1]
    raise AssertionError(f"could not brace-match {name}")


def _run_node(script: str) -> dict:
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    return json.loads(result.stdout)


def test_acknowledge_visit_clears_completion_unread_marker():
    """Visiting a session that carries an explicit completion-unread marker must
    clear it (so the yellow dot disappears) and repaint the sidebar."""
    ack = _extract("_acknowledgeSessionVisit")
    sync = _extract("_syncSessionListSnapshotOnVisit")
    set_viewed = _extract("_setSessionViewedCount")
    clear_unread = _extract("_clearSessionCompletionUnread")
    get_unread = _extract("_getSessionCompletionUnread")
    save_unread = _extract("_saveSessionCompletionUnread")
    get_counts = _extract("_getSessionViewedCounts")
    save_counts = _extract("_saveSessionViewedCounts")

    script = f"""
// Minimal localStorage shim.
const _store = {{}};
const localStorage = {{
  getItem: (k) => (k in _store ? _store[k] : null),
  setItem: (k, v) => {{ _store[k] = String(v); }},
  removeItem: (k) => {{ delete _store[k]; }},
  key: (i) => Object.keys(_store)[i] ?? null,
  get length() {{ return Object.keys(_store).length; }},
}};
const SESSION_VIEWED_COUNTS_KEY = 'v';
const SESSION_COMPLETION_UNREAD_KEY = 'u';
let _sessionViewedCounts = null;
let _sessionCompletionUnread = null;
const _sessionListSnapshotById = new Map();
const _sessionStreamingById = new Map();
let S = {{ session: {{ session_id: 'open', message_count: 5 }} }};
let repaints = 0;
function renderSessionListFromCache() {{ repaints += 1; }}
function _forgetObservedStreamingSession() {{}}
{get_counts}
{save_counts}
{get_unread}
{save_unread}
{clear_unread}
{set_viewed}
{sync}
{ack}
{unread_store_helpers.BLOCK}
// Seed a stale completion-unread marker for the open session.
_getSessionCompletionUnread()['open'] = {{message_count: 5, completed_at: 1}};
_saveSessionCompletionUnread();
const before = Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), 'open');
_acknowledgeSessionVisit('open', 5, 10);
const after = Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), 'open');
const snap = _sessionListSnapshotById.get('open');
console.log(JSON.stringify({{before, after, repaints, viewed: _sessionViewedCountValue(_getSessionViewedCounts()['open']), snap}}));
"""
    out = _run_node(script)
    assert out["before"] is True, "precondition: marker seeded"
    assert out["after"] is False, "visiting the session must clear the completion-unread marker"
    assert out["repaints"] >= 1, "visiting must repaint the sidebar from cache"
    assert out["viewed"] == 5, "viewed count must be synced to the current message count"
    assert out["snap"] == {"message_count": 5, "last_message_at": 10}, (
        "polling snapshot must be synced so a deferred list poll cannot re-flag the session"
    )


def test_visit_snapshot_prevents_deferred_poll_from_reflagging():
    """A deferred /api/sessions poll running just after a visit must NOT re-mark
    the open, unchanged session as a fresh background completion.

    This is the sticky-dot race: without the snapshot sync, the poll sees a
    session that "completed" relative to a stale/absent snapshot and re-flags it.
    """
    sync = _extract("_syncSessionListSnapshotOnVisit")
    set_viewed = _extract("_setSessionViewedCount")
    clear_unread = _extract("_clearSessionCompletionUnread")
    get_unread = _extract("_getSessionCompletionUnread")
    save_unread = _extract("_saveSessionCompletionUnread")
    get_counts = _extract("_getSessionViewedCounts")
    save_counts = _extract("_saveSessionViewedCounts")
    ack = _extract("_acknowledgeSessionVisit")
    transitions = _extract("_markPollingCompletionUnreadTransitions")
    effective = _extract("_isSessionEffectivelyStreaming")
    local = _extract("_isSessionLocallyStreaming")

    script = f"""
const _store = {{}};
const localStorage = {{
  getItem: (k) => (k in _store ? _store[k] : null),
  setItem: (k, v) => {{ _store[k] = String(v); }},
  removeItem: (k) => {{ delete _store[k]; }},
  key: (i) => Object.keys(_store)[i] ?? null,
  get length() {{ return Object.keys(_store).length; }},
}};
const SESSION_VIEWED_COUNTS_KEY = 'v';
const SESSION_COMPLETION_UNREAD_KEY = 'u';
let _sessionViewedCounts = null;
let _sessionCompletionUnread = null;
const _sessionListSnapshotById = new Map();
const _sessionStreamingById = new Map();
// open + focused/visible so the focus gate treats it as actively viewed too.
let S = {{ session: {{ session_id: 'open', message_count: 5 }}, busy: false }};
let repaints = 0;
function renderSessionListFromCache() {{ repaints += 1; }}
function _forgetObservedStreamingSession(sid) {{}}
function _rememberObservedStreamingSession() {{}}
function _getSessionObservedStreaming() {{ return {{}}; }}
function _rememberSessionListSource() {{}}
function _hasPendingUserMessageSignal(s) {{ return !!(s && (s.pending_user_message || s.has_pending_user_message)); }}
function _markSessionCompletionUnread(sid, count) {{
  _getSessionCompletionUnread()[sid] = {{message_count: count, completed_at: 1}};
  _saveSessionCompletionUnread();
}}
const document = {{ visibilityState: 'visible', hasFocus: () => true }};
let _loadingSessionId = null;
const _allSessionsScope = null;
const _sessionListSourceById = new Map();
{get_counts}
{save_counts}
{get_unread}
{save_unread}
{clear_unread}
{set_viewed}
{local}
{effective}
{sync}
{ack}
{unread_store_helpers.BLOCK}
function _isSessionActivelyViewedForList(sid) {{
  if (!sid || !S.session || S.session.session_id !== sid) return false;
  if (_loadingSessionId && _loadingSessionId !== sid) return false;
  if (document.visibilityState && document.visibilityState !== 'visible') return false;
  if (typeof document.hasFocus === 'function' && !document.hasFocus()) return false;
  return true;
}}
{transitions}
// Simulate: session was streaming, so a snapshot/streaming state exists.
_sessionStreamingById.set('open', true);
_sessionListSnapshotById.set('open', {{message_count: 4, last_message_at: 5}});
// User visits — acknowledge marks it read AND syncs the snapshot to current.
_acknowledgeSessionVisit('open', 5, 10);
// Now a deferred /api/sessions poll lands for the SAME (now idle) session.
_markPollingCompletionUnreadTransitions([
  {{session_id: 'open', is_streaming: false, active_stream_id: null, message_count: 5, last_message_at: 10, updated_at: 10}}
]);
const flagged = Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), 'open');
console.log(JSON.stringify({{flagged}}));
"""
    out = _run_node(script)
    assert out["flagged"] is False, (
        "a deferred list poll after a visit must not re-flag the open, unchanged "
        "session as unread — the visit-ack synced snapshot + viewed count"
    )


# ── Functional: hidden-tab completion during message load stays unread ───────

def _extract_async(name: str) -> str:
    """Like _extract, but preserve an `async` prefix so `await` bodies stay valid."""
    body = _extract(name)
    idx = SESSIONS_JS.rindex("async ", 0, SESSIONS_JS.index(body))
    # Only treat it as async if the `async ` keyword immediately precedes it.
    if SESSIONS_JS[idx:idx + len("async ")] == "async " and \
       SESSIONS_JS[idx:].startswith("async function " + name):
        return "async " + body
    return body


def _hidden_completion_script(*, hidden: bool) -> str:
    """Build a Node harness that drives the REAL _ensureMessagesLoaded() through a
    delayed messages fetch, marks a completion mid-fetch, and reports whether the
    completion-unread marker survives."""
    ensure = _extract_async("_ensureMessagesLoaded")
    set_viewed = _extract("_setSessionViewedCount")
    clear_unread = _extract("_clearSessionCompletionUnread")
    get_unread = _extract("_getSessionCompletionUnread")
    save_unread = _extract("_saveSessionCompletionUnread")
    mark_unread = _extract("_markSessionCompletionUnread")
    get_counts = _extract("_getSessionViewedCounts")
    save_counts = _extract("_saveSessionViewedCounts")
    actively_viewed = _extract("_isSessionActivelyViewedForList")

    visibility = "'hidden'" if hidden else "'visible'"
    has_focus = "false" if hidden else "true"

    return f"""
// Minimal localStorage shim.
const _store = {{}};
const localStorage = {{
  getItem: (k) => (k in _store ? _store[k] : null),
  setItem: (k, v) => {{ _store[k] = String(v); }},
  removeItem: (k) => {{ delete _store[k]; }},
  key: (i) => Object.keys(_store)[i] ?? null,
  get length() {{ return Object.keys(_store).length; }},
}};
const SESSION_VIEWED_COUNTS_KEY = 'v';
const SESSION_COMPLETION_UNREAD_KEY = 'u';
let _sessionViewedCounts = null;
let _sessionCompletionUnread = null;
let _messagesTruncated = false;
let _oldestIdx = 0;
let _messageRenderWindowSize = 0;
const _MSG_LIMIT_MAX = 500;
let _msgLimitMax = _MSG_LIMIT_MAX;
let _pendingCarryForwardSnapshot = null;
let _loadingSessionId = 'open';
let _loadSessionGeneration = 0;
const window = {{}};
// Tab starts VISIBLE+FOCUSED: the load begins while the user is watching.
let _visibility = 'visible';
let _focused = true;
const document = {{
  get visibilityState() {{ return _visibility; }},
  hasFocus: () => _focused,
}};
let S = {{ session: {{ session_id: 'open', message_count: 0 }}, messages: [], lastUsage: {{}} }};

// Stubs for the incidental side effects _ensureMessagesLoaded touches.
function _clearSameSessionForceReloadHint() {{}}
function _messageReloadLimitForSession() {{ return 0; }}
function _syncToolCallsForLoadedMessages() {{}}
function clearLiveToolCards() {{}}

// Delayed messages fetch: resolves only when we release it, simulating a slow
// /api/session?messages=1 response that spans the tab going hidden.
let _releaseApi;
const _apiResult = new Promise((res) => {{ _releaseApi = res; }});
let _apiCalled = false;
async function api(url) {{ _apiCalled = true; return _apiResult; }}

{get_counts}
{save_counts}
{get_unread}
{save_unread}
{clear_unread}
{mark_unread}
{set_viewed}
{actively_viewed}
{ensure}
{unread_store_helpers.BLOCK}

function _hasMarker() {{
  return Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), 'open');
}}

(async () => {{
  const p = _ensureMessagesLoaded('open', {{}});
  // Let the synchronous body run up to `await api(...)`.
  await Promise.resolve();
  await Promise.resolve();
  const apiIssued = _apiCalled;
  // Mid-fetch: the tab's visibility/focus flips to the test scenario and a
  // background completion lands, marking the session unread.
  _visibility = {visibility};
  _focused = {has_focus};
  _markSessionCompletionUnread('open', 6);
  const markerBefore = _hasMarker();
  // The delayed messages response finally arrives and the load finishes.
  _releaseApi({{ session: {{ session_id: 'open', message_count: 6, messages: [{{ role: 'assistant', content: 'x' }}] }} }});
  await p;
  const markerAfter = _hasMarker();
  const viewedRecord = _getSessionViewedCounts()['open'];
  const viewed = viewedRecord === undefined ? null : _sessionViewedCountValue(viewedRecord);
  console.log(JSON.stringify({{
    apiIssued,
    markerBefore,
    markerAfter,
    viewed,
  }}));
}})();
"""


def test_hidden_tab_completion_during_message_load_survives():
    """#5917 gate finding (SILENT): a completion that lands while the tab is
    hidden DURING the awaited message fetch inside _ensureMessagesLoaded() must
    NOT be silently marked read. The guarded viewed-count clear must skip when
    the session is no longer actively viewed."""
    out = _run_node(_hidden_completion_script(hidden=True))
    assert out["apiIssued"] is True, "precondition: the delayed messages fetch was issued"
    assert out["markerBefore"] is True, "precondition: the mid-fetch completion marked the session unread"
    assert out["markerAfter"] is True, (
        "a hidden-tab completion landing during the awaited message fetch must "
        "SURVIVE — _ensureMessagesLoaded() must not clear the unread marker via "
        "an unconditional _setSessionViewedCount when the tab is not actively viewing"
    )
    assert out["viewed"] is None, (
        "the viewed count must NOT be synced for a hidden/background session, "
        "otherwise the completion-unread marker would be cleared"
    )


def test_visible_tab_message_load_still_syncs_viewed_count():
    """Control: the guard must not over-block. An ACTIVELY-viewed session still
    updates its viewed count normally through the message-load path (clearing any
    marker), so the fix is scoped to hidden/background sessions only."""
    out = _run_node(_hidden_completion_script(hidden=False))
    assert out["apiIssued"] is True
    assert out["markerBefore"] is True
    assert out["markerAfter"] is False, (
        "an actively-viewed session must still clear its completion-unread marker "
        "when the message load finishes"
    )
    assert out["viewed"] == 6, (
        "an actively-viewed session must still sync its viewed count to the "
        "loaded message count"
    )
