"""Regression coverage for #2539 client-side api() timeout handling."""

from __future__ import annotations

import json
import re
import subprocess
import textwrap
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
WORKSPACE_JS = ROOT / "static" / "workspace.js"
SESSIONS_JS = ROOT / "static" / "sessions.js"
UI_JS = ROOT / "static" / "ui.js"
PANELS_JS = ROOT / "static" / "panels.js"


def _source(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def _extract_js_function(src: str, name: str) -> str:
    marker = f"async function {name}("
    start = src.find(marker)
    assert start >= 0, f"{name}() function must exist"
    # The api() signature contains a default object literal (`opts={}`), so the
    # function-body brace is the first `{` after the balanced parameter list.
    paren_depth = 0
    close_paren = -1
    for idx in range(start + len(f"async function {name}"), len(src)):
        ch = src[idx]
        if ch == "(":
            paren_depth += 1
        elif ch == ")":
            paren_depth -= 1
            if paren_depth == 0:
                close_paren = idx
                break
    assert close_paren > start, f"{name}() parameter list must close"
    brace = src.find("{", close_paren)
    assert brace > close_paren, f"{name}() function body must start with {{"
    depth = 0
    in_string: str | None = None
    escaped = False
    in_line_comment = False
    in_block_comment = False
    for idx in range(brace, len(src)):
        ch = src[idx]
        nxt = src[idx + 1] if idx + 1 < len(src) else ""
        if in_line_comment:
            if ch == "\n":
                in_line_comment = False
            continue
        if in_block_comment:
            if ch == "*" and nxt == "/":
                in_block_comment = False
            continue
        if in_string:
            if escaped:
                escaped = False
            elif ch == "\\":
                escaped = True
            elif ch == in_string:
                in_string = None
            continue
        if ch == "/" and nxt == "/":
            in_line_comment = True
            continue
        if ch == "/" and nxt == "*":
            in_block_comment = True
            continue
        if ch in ("'", '"', "`"):
            in_string = ch
            continue
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return src[start : idx + 1]
    raise AssertionError(f"could not extract {name}() body")


def _node_eval(script: str, timeout: float = 2.0) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        ["node", "-e", script],
        cwd=ROOT,
        text=True,
        capture_output=True,
        timeout=timeout,
        check=False,
    )


def test_api_rejects_hung_fetch_with_timeout_and_toast():
    """A hung fetch must reject quickly and surface a recognizable timeout toast."""
    api_fn = _extract_js_function(_source(WORKSPACE_JS), "api")
    script = textwrap.dedent(
        f"""
        const events=[];
        global.document={{baseURI:'http://example.test/hermes/'}};
        global.location={{href:'http://example.test/hermes/',pathname:'/hermes/',search:''}};
        global.window={{location:global.location}};
        global.showToast=(msg,ms,type)=>events.push({{msg:String(msg),ms,type}});
        global.fetch=(url,opts)=>new Promise(()=>{{
          if(opts&&opts.signal)opts.signal.addEventListener('abort',()=>events.push({{aborted:true}}));
        }});
        {api_fn}
        api('/api/sessions',{{timeoutMs:20}})
          .then(()=>{{console.error('resolved unexpectedly');process.exit(2);}})
          .catch(err=>{{
            console.log(JSON.stringify({{message:String(err&&err.message||err),events}}));
            process.exit(0);
          }});
        setTimeout(()=>{{console.error('api did not reject after timeoutMs');process.exit(3);}},250);
        """
    )
    result = _node_eval(script, timeout=1.0)
    assert result.returncode == 0, result.stderr or result.stdout
    payload = json.loads(result.stdout.strip())
    assert "timed out" in payload["message"].lower()
    assert any(event.get("aborted") for event in payload["events"]), payload
    assert any("request timed out" in event.get("msg", "").lower() for event in payload["events"]), payload
    assert any(event.get("type") == "error" for event in payload["events"]), payload


def test_api_rejects_stalled_response_body_with_timeout():
    """The timeout must stay active through JSON/text body consumption, not only headers."""
    api_fn = _extract_js_function(_source(WORKSPACE_JS), "api")
    script = textwrap.dedent(
        f"""
        const events=[];
        global.document={{baseURI:'http://example.test/hermes/'}};
        global.location={{href:'http://example.test/hermes/',pathname:'/hermes/',search:''}};
        global.window={{location:global.location}};
        global.showToast=(msg,ms,type)=>events.push({{msg:String(msg),ms,type}});
        global.fetch=(url,opts)=>Promise.resolve({{
          ok:true,
          headers:{{get:()=> 'application/json'}},
          json:()=>new Promise(()=>{{
            if(opts&&opts.signal)opts.signal.addEventListener('abort',()=>events.push({{aborted:true}}));
          }}),
          text:()=>Promise.resolve('')
        }});
        {api_fn}
        api('/api/sessions',{{timeoutMs:20}})
          .then(()=>{{console.error('resolved unexpectedly');process.exit(2);}})
          .catch(err=>{{
            console.log(JSON.stringify({{message:String(err&&err.message||err),events}}));
            process.exit(0);
          }});
        setTimeout(()=>{{console.error('api body read did not reject after timeoutMs');process.exit(3);}},250);
        """
    )
    result = _node_eval(script, timeout=1.0)
    assert result.returncode == 0, result.stderr or result.stdout
    payload = json.loads(result.stdout.strip())
    assert "timed out" in payload["message"].lower()
    assert any(event.get("aborted") for event in payload["events"]), payload


def test_api_has_default_timeout_and_per_call_override_contract():
    src = _source(WORKSPACE_JS)
    body = _extract_js_function(src, "api")
    assert "timeoutMs" in body, "api() must accept opts.timeoutMs as a per-call override"
    assert "30000" in body, "api() must default browser API calls to a 30s timeout"
    assert "AbortController" in body, "api() must abort hung fetches with AbortController"
    assert "delete fetchOpts.timeoutMs" in body, "api() must strip timeoutMs before calling fetch()"
    fetch_call = re.search(r"fetch\(url\.href,\{.*?\.\.\.fetchOpts.*?\}\)", body, re.DOTALL)
    assert fetch_call, "api() must call fetch() with sanitized fetchOpts"
    assert "...opts" not in fetch_call.group(0), "api() must not spread raw opts into fetch()"
    assert "timeoutMs" not in fetch_call.group(0), "api() must not forward timeoutMs to fetch()"


def test_update_flows_keep_explicit_longer_timeouts():
    """Legitimately long update flows should not inherit the generic 30s guard."""
    src = _source(UI_JS)
    panels = _source(PANELS_JS)
    assert "api('/api/updates/check?force=1',{timeoutMs:60000})" in panels
    assert "api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null}),timeoutMs:60000})" in src
    assert "api('/api/updates/apply',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src
    assert "api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src


def test_new_session_inflight_cleanup_still_runs_after_api_rejects():
    """newSession() must keep its finally cleanup path so timeout rejections unpin the UI."""
    src = _source(SESSIONS_JS)
    start = src.find("async function newSession")
    assert start >= 0, "newSession() must exist"
    finally_idx = src.find("}finally{", start)
    assert finally_idx > start, "newSession() must keep a finally cleanup block"
    block = src[finally_idx : src.find("\n}", finally_idx) + 2]
    assert "_newSessionInFlight=null" in block
    assert "_setNewSessionPending(false)" in block
